Skip to content

Commit 5eee16a

Browse files
committed
Add custom currency support via custom_currency_path config
Allow apps to define custom currencies (e.g., loyalty points) by pointing Money::Config to a YAML file. Lookup order: ISO → crypto → custom, so custom currencies cannot shadow built-in ones. Uses YAML.safe_load_file for user-provided paths and a path-keyed cache to support per-Fiber config isolation.
1 parent 28989f1 commit 5eee16a

9 files changed

Lines changed: 222 additions & 5 deletions

File tree

lib/money/config.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def thread_local_config
4141
end
4242
end
4343

44-
attr_accessor :legacy_json_format, :experimental_crypto_currencies, :default_subunit_format
44+
attr_accessor :legacy_json_format, :experimental_crypto_currencies, :default_subunit_format, :custom_currency_path
4545

4646
attr_reader :default_currency
4747
alias_method :currency, :default_currency
@@ -72,6 +72,7 @@ def initialize
7272
@legacy_json_format = false
7373
@experimental_crypto_currencies = false
7474
@default_subunit_format = :iso4217
75+
@custom_currency_path = nil
7576
end
7677
end
7778
end

lib/money/currency.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ def crypto_currencies
3131
@@crypto_currencies ||= Loader.load_crypto_currencies
3232
end
3333

34+
def custom_currencies(path)
35+
@@custom_currencies_cache ||= {}
36+
@@custom_currencies_cache[path] ||= Loader.load_custom_currencies(path)
37+
end
38+
39+
def reset_custom_currencies
40+
@@custom_currencies_cache = nil
41+
end
42+
3443
def reset_loaded_currencies
3544
@@loaded_currencies = {}
3645
end
@@ -52,6 +61,10 @@ def initialize(currency_iso)
5261
if data.nil? && Money::Config.current.experimental_crypto_currencies
5362
data = self.class.crypto_currencies[currency_iso]
5463
end
64+
if data.nil?
65+
custom_path = Money::Config.current.custom_currency_path
66+
data = self.class.custom_currencies(custom_path)[currency_iso] if custom_path
67+
end
5568

5669
raise UnknownCurrency, "Invalid iso4217 currency '#{currency_iso}'" unless data
5770
@symbol = data['symbol']

lib/money/currency/loader.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ def load_crypto_currencies
2222
deep_deduplicate!(currencies)
2323
end
2424

25+
def load_custom_currencies(path)
26+
data = YAML.safe_load_file(path, permitted_classes: [])
27+
raise ArgumentError, "Custom currency file must contain a YAML hash" unless data.is_a?(Hash)
28+
deep_deduplicate!(data)
29+
end
30+
2531
private
2632

2733
def deep_deduplicate!(data)

sig/money/config.rbs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Money
77
attr_accessor legacy_json_format: bool
88
attr_accessor experimental_crypto_currencies: bool
99
attr_accessor default_subunit_format: Symbol
10+
attr_accessor custom_currency_path: String?
1011

1112
attr_reader default_currency: (Currency | NullCurrency | nil)
1213
def default_currency=: (String | Currency | NullCurrency | nil value) -> (Currency | NullCurrency | nil)
@@ -16,7 +17,7 @@ class Money
1617
def self.global: () -> Config
1718
def self.current: () -> Config
1819
def self.current=: (Config config) -> Config
19-
def self.configure_current: [T] (?currency: (String | Currency | NullCurrency | nil), ?legacy_json_format: bool, ?experimental_crypto_currencies: bool, ?default_subunit_format: Symbol) { () -> T } -> T
20+
def self.configure_current: [T] (?currency: (String | Currency | NullCurrency | nil), ?legacy_json_format: bool, ?experimental_crypto_currencies: bool, ?default_subunit_format: Symbol, ?custom_currency_path: String?) { () -> T } -> T
2021
def self.reset_current: () -> nil
2122

2223
private

sig/money/currency.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Money
77

88
def self.load_currencies: () -> Hash[String, untyped]
99
def self.load_crypto_currencies: () -> Hash[String, untyped]
10+
def self.load_custom_currencies: (String path) -> Hash[String, untyped]
1011

1112
private
1213

@@ -31,6 +32,8 @@ class Money
3132
def self.find: (String | Symbol currency_iso) -> Currency?
3233
def self.currencies: () -> Hash[String, Hash[String, untyped]]
3334
def self.crypto_currencies: () -> Hash[String, Hash[String, untyped]]
35+
def self.custom_currencies: (String path) -> Hash[String, Hash[String, untyped]]
36+
def self.reset_custom_currencies: () -> nil
3437
def self.reset_loaded_currencies: () -> Hash[String, Currency]
3538

3639
def initialize: (String currency_iso) -> void

spec/config_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@
7272
end
7373
end
7474

75+
describe 'custom_currency_path' do
76+
it 'defaults to nil' do
77+
expect(Money::Config.new.custom_currency_path).to eq(nil)
78+
end
79+
80+
it 'can be set to a path' do
81+
config = Money::Config.new
82+
config.custom_currency_path = '/tmp/custom.yml'
83+
expect(config.custom_currency_path).to eq('/tmp/custom.yml')
84+
end
85+
end
86+
7587
describe 'legacy_json_format' do
7688
it 'defaults to false' do
7789
expect(Money::Config.new.legacy_json_format).to eq(false)

spec/currency/loader_spec.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22
require 'spec_helper'
3+
require 'tempfile'
34

45
RSpec.describe Money::Currency::Loader do
56

@@ -37,4 +38,53 @@
3738
expect(currencies['usdc']['iso_code']).to be_frozen
3839
end
3940
end
41+
42+
describe 'load_custom_currencies' do
43+
it 'loads custom currencies from the given path' do
44+
file = Tempfile.new(['custom_currencies', '.yml'])
45+
file.write({
46+
"credits" => {
47+
"iso_code" => "CREDITS",
48+
"name" => "Loyalty Points",
49+
"symbol" => "CR",
50+
"disambiguate_symbol" => "CR",
51+
"subunit_to_unit" => 1,
52+
"smallest_denomination" => 1,
53+
"decimal_mark" => "."
54+
}
55+
}.to_yaml)
56+
file.close
57+
58+
currencies = subject.load_custom_currencies(file.path)
59+
expect(currencies['credits']['iso_code']).to eq('CREDITS')
60+
expect(currencies['credits']['name']).to eq('Loyalty Points')
61+
expect(currencies['credits']['symbol']).to eq('CR')
62+
expect(currencies['credits']['subunit_to_unit']).to eq(1)
63+
ensure
64+
file.unlink
65+
end
66+
67+
it 'returns frozen and deduplicated data' do
68+
file = Tempfile.new(['custom_currencies', '.yml'])
69+
file.write({
70+
"credits" => {
71+
"iso_code" => "CREDITS",
72+
"name" => "Loyalty Points",
73+
"symbol" => "CR",
74+
"disambiguate_symbol" => "CR",
75+
"subunit_to_unit" => 1,
76+
"smallest_denomination" => 1,
77+
"decimal_mark" => "."
78+
}
79+
}.to_yaml)
80+
file.close
81+
82+
currencies = subject.load_custom_currencies(file.path)
83+
expect(currencies).to be_frozen
84+
expect(currencies['credits']).to be_frozen
85+
expect(currencies['credits']['iso_code']).to be_frozen
86+
ensure
87+
file.unlink
88+
end
89+
end
4090
end

spec/currency_spec.rb

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# frozen_string_literal: true
22
require 'spec_helper'
3+
require 'tempfile'
34

45
RSpec.describe "Currency" do
56
CURRENCY_DATA = {
@@ -17,6 +18,20 @@
1718

1819
let(:currency) { Money::Currency.new('usd') }
1920

21+
let(:mock_custom_currency) do
22+
{
23+
"credits" => {
24+
"iso_code" => "CREDITS",
25+
"name" => "Loyalty Points",
26+
"symbol" => "CR",
27+
"disambiguate_symbol" => "CR",
28+
"subunit_to_unit" => 1,
29+
"smallest_denomination" => 1,
30+
"decimal_mark" => "."
31+
}
32+
}
33+
end
34+
2035
let(:mock_crypto_currency) do
2136
{
2237
"usdc" => {
@@ -69,6 +84,89 @@
6984
expect(Money::Currency.find("USDC")).to be_nil
7085
end
7186
end
87+
88+
it "looks up custom currencies when path is set" do
89+
allow(Money::Currency).to receive(:currencies).and_return({})
90+
allow(Money::Currency).to receive(:custom_currencies).with('/tmp/custom.yml').and_return(mock_custom_currency)
91+
92+
configure(custom_currency_path: '/tmp/custom.yml') do
93+
currency = Money::Currency.new('CREDITS')
94+
expect(currency.iso_code).to eq('CREDITS')
95+
expect(currency.symbol).to eq('CR')
96+
end
97+
end
98+
99+
it "doesn't look up custom currencies when path is nil" do
100+
expect(Money::Currency.find("CREDITS")).to eq(nil)
101+
end
102+
103+
it "can't override ISO currencies with custom currencies" do
104+
allow(Money::Currency).to receive(:custom_currencies).with('/tmp/custom.yml').and_return(
105+
"usd" => {
106+
"iso_code" => "USD",
107+
"name" => "Fake Dollar",
108+
"symbol" => "FAKE",
109+
"disambiguate_symbol" => "FAKE",
110+
"subunit_to_unit" => 1,
111+
"smallest_denomination" => 1,
112+
"decimal_mark" => "."
113+
}
114+
)
115+
116+
configure(custom_currency_path: '/tmp/custom.yml') do
117+
currency = Money::Currency.new('USD')
118+
expect(currency.name).to eq('United States Dollar')
119+
expect(currency.symbol).to eq('$')
120+
end
121+
end
122+
123+
it "can't override crypto currencies with custom currencies" do
124+
allow(Money::Currency).to receive(:currencies).and_return({})
125+
allow(Money::Currency).to receive(:crypto_currencies).and_return(mock_crypto_currency)
126+
allow(Money::Currency).to receive(:custom_currencies).with('/tmp/custom.yml').and_return(
127+
"usdc" => {
128+
"iso_code" => "USDC",
129+
"name" => "Fake USDC",
130+
"symbol" => "FAKE",
131+
"disambiguate_symbol" => "FAKE",
132+
"subunit_to_unit" => 1,
133+
"smallest_denomination" => 1,
134+
"decimal_mark" => "."
135+
}
136+
)
137+
138+
configure(experimental_crypto_currencies: true, custom_currency_path: '/tmp/custom.yml') do
139+
currency = Money::Currency.new('USDC')
140+
expect(currency.name).to eq('USD Coin')
141+
expect(currency.symbol).to eq('USDC')
142+
end
143+
end
144+
145+
it "loads custom currencies end-to-end from a YAML file" do
146+
file = Tempfile.new(['custom_currencies', '.yml'])
147+
file.write({
148+
"credits" => {
149+
"iso_code" => "CREDITS",
150+
"name" => "Loyalty Points",
151+
"symbol" => "CR",
152+
"disambiguate_symbol" => "CR",
153+
"subunit_to_unit" => 1,
154+
"smallest_denomination" => 1,
155+
"decimal_mark" => "."
156+
}
157+
}.to_yaml)
158+
file.close
159+
160+
configure(custom_currency_path: file.path) do
161+
money = Money.new(500, "CREDITS")
162+
expect(money.currency.iso_code).to eq("CREDITS")
163+
expect(money.currency.symbol).to eq("CR")
164+
expect(money.currency.name).to eq("Loyalty Points")
165+
expect(money.value).to eq(500)
166+
end
167+
ensure
168+
file.unlink
169+
end
72170
end
73171

74172
describe ".find" do
@@ -96,6 +194,20 @@
96194
expect(Money::Currency.find('USDC')).to eq(nil)
97195
end
98196
end
197+
198+
it "returns custom currency when path is set" do
199+
allow(Money::Currency).to receive(:currencies).and_return({})
200+
allow(Money::Currency).to receive(:custom_currencies).with('/tmp/custom.yml').and_return(mock_custom_currency)
201+
202+
configure(custom_currency_path: '/tmp/custom.yml') do
203+
expect(Money::Currency.find('CREDITS')).not_to eq(nil)
204+
expect(Money::Currency.find('CREDITS').iso_code).to eq('CREDITS')
205+
end
206+
end
207+
208+
it "returns nil for custom currency when path is not set" do
209+
expect(Money::Currency.find('CREDITS')).to eq(nil)
210+
end
99211
end
100212

101213
describe ".find!" do
@@ -123,6 +235,23 @@
123235
end
124236
end
125237

238+
describe ".custom_currencies" do
239+
it "loads custom currencies from the loader" do
240+
old_cache = Money::Currency.class_variable_get(:@@custom_currencies_cache) rescue nil
241+
Money::Currency.class_variable_set(:@@custom_currencies_cache, nil)
242+
243+
allow(Money::Currency::Loader).to receive(:load_custom_currencies)
244+
.with('/tmp/custom.yml')
245+
.and_return(mock_custom_currency)
246+
247+
expect(Money::Currency.custom_currencies('/tmp/custom.yml')).to eq(mock_custom_currency)
248+
expect(Money::Currency.custom_currencies('/tmp/custom.yml')).to eq(mock_custom_currency) # Second call to verify caching
249+
expect(Money::Currency::Loader).to have_received(:load_custom_currencies).with('/tmp/custom.yml').once
250+
251+
Money::Currency.class_variable_set(:@@custom_currencies_cache, old_cache) if old_cache
252+
end
253+
end
254+
126255
CURRENCY_DATA.each do |attribute, value|
127256
describe "##{attribute}" do
128257
it 'returns the correct value' do

spec/spec_helper.rb

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,19 @@ def missing_methods
7575
end
7676

7777

78-
def configure(default_currency: nil, legacy_json_format: nil, experimental_crypto_currencies: nil)
78+
def configure(default_currency: nil, legacy_json_format: nil, experimental_crypto_currencies: nil, custom_currency_path: nil)
7979
Money::Config.current = Money::Config.new.tap do |config|
8080
config.default_currency = default_currency if default_currency
8181
config.legacy_json_format! if legacy_json_format
82-
config.experimental_crypto_currencies! if experimental_crypto_currencies
82+
config.experimental_crypto_currencies = experimental_crypto_currencies unless experimental_crypto_currencies.nil?
83+
config.custom_currency_path = custom_currency_path if custom_currency_path
8384
end
84-
Money::Currency.reset_loaded_currencies if experimental_crypto_currencies == false
8585

8686
yield
8787
ensure
8888
Money::Config.reset_current
89+
Money::Currency.reset_loaded_currencies
90+
Money::Currency.reset_custom_currencies if custom_currency_path
8991
end
9092

9193
def yaml_load(yaml)

0 commit comments

Comments
 (0)