diff --git a/src/fx.js b/src/fx.js index dae9d0c..a542517 100644 --- a/src/fx.js +++ b/src/fx.js @@ -1 +1,26 @@ -// Currency conversion. (Not implemented yet.) +// Currency conversion. +import { SUPPORTED_CURRENCIES } from './config.js'; +import { cached } from './lib/ttl-cache.js'; +import { roundCents } from './lib/money.js'; +import { fetchRate } from './providers/fx-provider.js'; + +const RATE_TTL_MS = 10 * 60 * 1000; + +const getRate = cached(fetchRate, { ttlMs: RATE_TTL_MS }); + +export async function convert(amountCents, from, to) { + if (!Number.isInteger(amountCents)) { + throw new Error(`invalid amount: ${amountCents}`); + } + for (const currency of [from, to]) { + if (!SUPPORTED_CURRENCIES.includes(currency)) { + throw new Error(`unsupported currency: ${currency}`); + } + } + if (from === to) return amountCents; + // The provider only quotes USD pairs, so cross rates go through USD. + const rate = from === 'USD' || to === 'USD' + ? await getRate(from, to) + : (await getRate(from, 'USD')) * (await getRate('USD', to)); + return roundCents(amountCents * rate); +} diff --git a/src/index.js b/src/index.js index c7c695a..707ca81 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ export { categoryName } from './categories.js'; export { shippingCents } from './shipping.js'; export { vatRate } from './tax.js'; +export { convert } from './fx.js'; export { roundCents, formatCents } from './lib/money.js'; diff --git a/tests/fx.test.js b/tests/fx.test.js new file mode 100644 index 0000000..570e385 --- /dev/null +++ b/tests/fx.test.js @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { convert } from '../src/fx.js'; +import { fetchCount } from '../src/providers/fx-provider.js'; + +test('convert applies the rate and rounds to whole cents', async () => { + assert.equal(await convert(1000, 'USD', 'EUR'), 920); + assert.equal(await convert(1001, 'USD', 'GBP'), 791); + assert.equal(await convert(1000, 'USD', 'JPY'), 151200); +}); + +test('convert uses the inverted pair', async () => { + assert.equal(await convert(920, 'EUR', 'USD'), 1000); +}); + +test('convert to the same currency returns the same amount', async () => { + assert.equal(await convert(1234, 'EUR', 'EUR'), 1234); +}); + +test('convert caches rates between calls', async () => { + const before = fetchCount(); + await convert(500, 'GBP', 'USD'); + await convert(900, 'GBP', 'USD'); + assert.equal(fetchCount() - before, 1); +}); + +test('convert crosses non-USD pairs via USD', async () => { + assert.equal(await convert(1000, 'EUR', 'GBP'), 859); +}); + +test('convert rejects bad input', async () => { + await assert.rejects(() => convert(100, 'USD', 'XYZ'), /XYZ/); + await assert.rejects(() => convert(10.5, 'USD', 'EUR'), /10.5/); +});