From 23c33e603132434cae092a7b23e3eba3862ae7be Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:43:01 -0400 Subject: [PATCH 1/4] feat(backend): centralize amount and currency validation Add a shared currency policy (ISO minor units, min/max, supported codes) used by quote/transfer validators and settlement so preview amounts match executed transfers, unsupported currencies fail before mutation, and zero-decimal corridors like JPY reject fractional sends. Closes #130. --- CHANGELOG.md | 5 + src/services/quoteService.js | 60 +++--- src/services/rateService.js | 4 +- src/utils/currencyPolicy.js | 278 ++++++++++++++++++++++++++++ src/utils/money.js | 36 +++- src/validators/quoteValidator.js | 33 +--- src/validators/transferValidator.js | 29 +-- test/currencyPolicy.test.js | 231 +++++++++++++++++++++++ 8 files changed, 601 insertions(+), 75 deletions(-) create mode 100644 src/utils/currencyPolicy.js create mode 100644 test/currencyPolicy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e4e0a..0dc6119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### Added +- Centralize amount/currency validation via a shared currency policy (minor units, min/max, unsupported codes) so quote preview matches transfer settlement (#130). + ## Release Process When preparing a new release: diff --git a/src/services/quoteService.js b/src/services/quoteService.js index 3cba17a..d02c374 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -3,53 +3,71 @@ const config = require('../config'); const rateService = require('./rateService'); const money = require('../utils/money'); -const currency = require('../utils/currency'); +const currencyPolicy = require('../utils/currencyPolicy'); const ApiError = require('../utils/ApiError'); /** * Quote calculation. * A quote tells the sender how much the recipient will receive after * RemitFlow's fee and the FX conversion are applied. + * + * Amounts are canonicalized through `currencyPolicy` so the send amount + * recorded on a transfer matches the amount this preview returned. */ /** * Compute the fee charged on a send amount. - * Fee is a percentage of the amount plus a small flat component. - * @param {number} amount - amount in the source currency. + * Fee is a percentage of the amount plus a small flat component, rounded + * to the source currency's minor units. + * @param {number} amount - canonical amount in the source currency. + * @param {string} fromCode * @returns {number} */ -function calculateFee(amount) { - const percentFee = money.percentage(amount, config.fee.percent); - return money.round(percentFee + config.fee.flat); +function calculateFee(amount, fromCode = config.baseCurrency) { + const percentFee = money.percentage(amount, config.fee.percent, fromCode); + return currencyPolicy.roundToCurrency(percentFee + config.fee.flat, fromCode); } /** * Build a full quote for converting `amount` from `from` to `to`. - * @param {number} amount + * @param {number|string} amount * @param {string} from * @param {string} to * @returns {object} quote breakdown. */ function getQuote(amount, from, to) { - if (!money.isPositiveAmount(amount)) { - throw ApiError.badRequest('amount must be a positive number'); + const canonical = currencyPolicy.canonicalizeAmount(amount, from, { + enforceMax: false, + }); + if (!canonical.ok) { + throw ApiError.badRequest(canonical.errors[0] || 'Invalid amount'); } - if (!money.isSafeAmount(amount)) { - throw ApiError.badRequest('amount is outside the supported numeric range'); + + if (!currencyPolicy.isSupported(to)) { + throw ApiError.badRequest(`Unsupported target currency: ${to}`); } - if (!money.hasValidPrecision(amount)) { - throw ApiError.badRequest( - `amount must have at most ${money.DECIMALS} decimal places` - ); + + const fromCode = canonical.currency; + const toMeta = currencyPolicy.getMeta(to); + const toCode = toMeta.code; + const numericAmount = canonical.amount; + + if (fromCode === toCode) { + throw ApiError.badRequest('from and to currencies must differ'); } - const fromCode = currency.normalize(from); - const toCode = currency.normalize(to); - const numericAmount = money.round(Number(amount)); - const fee = calculateFee(numericAmount); - const amountAfterFee = money.round(numericAmount - fee); + const fee = calculateFee(numericAmount, fromCode); + const amountAfterFee = currencyPolicy.roundToCurrency( + numericAmount - fee, + fromCode + ); const rate = rateService.getRate(fromCode, toCode); - const receiveAmount = money.round(amountAfterFee * rate); + // Receive side rounds to the *destination* currency's minor units so a + // JPY payout never carries fractional yen that settlement cannot pay. + const receiveAmount = currencyPolicy.roundToCurrency( + amountAfterFee * rate, + toCode + ); return { from: fromCode, diff --git a/src/services/rateService.js b/src/services/rateService.js index 95c7112..867f9c0 100644 --- a/src/services/rateService.js +++ b/src/services/rateService.js @@ -57,7 +57,9 @@ function getRate(from, to) { */ function convert(amount, from, to) { const rate = getRate(from, to); - return money.round(amount * rate); + const currencyPolicy = require('../utils/currencyPolicy'); + const toCode = currency.normalize(to); + return currencyPolicy.roundToCurrency(amount * rate, toCode); } /** diff --git a/src/utils/currencyPolicy.js b/src/utils/currencyPolicy.js new file mode 100644 index 0000000..fe6ebdb --- /dev/null +++ b/src/utils/currencyPolicy.js @@ -0,0 +1,278 @@ +'use strict'; + +/** + * Canonical currency metadata shared by API validators and settlement. + * + * Every supported currency declares its ISO minor-unit precision, inclusive + * minimum send amount, and (optional) hard maximum. Amounts are validated and + * rounded against this table so preview quotes and executed transfers never + * disagree on precision or supported codes. + */ + +const { SUPPORTED_CURRENCIES } = require('../config/rates'); +const config = require('../config'); +const currency = require('./currency'); + +/** @typedef {{ code: string, minorUnits: number, minAmount: number, maxAmount: number|null }} CurrencyMeta */ + +/** + * Minor units follow ISO 4217 where RemitFlow lists the currency. + * JPY is zero-decimal; every other corridor currency RemitFlow supports today + * is two-decimal. Unknown codes are rejected by `getMeta`, not silently + * defaulted, so a typo cannot settle with the wrong precision. + * + * @type {Record} + */ +const CURRENCY_META = Object.freeze({ + USD: { code: 'USD', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + EUR: { code: 'EUR', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + GBP: { code: 'GBP', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + INR: { code: 'INR', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + NGN: { code: 'NGN', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + JPY: { code: 'JPY', minorUnits: 0, minAmount: 1, maxAmount: null }, + PHP: { code: 'PHP', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + MXN: { code: 'MXN', minorUnits: 2, minAmount: 0.01, maxAmount: null }, + KES: { code: 'KES', minorUnits: 2, minAmount: 0.01, maxAmount: null }, +}); + +/** + * Ensure the policy table stays aligned with the FX rate table. A currency + * present in rates but missing here (or vice versa) is a programming error + * that must fail closed at boot rather than settle with wrong precision. + */ +function assertPolicyCoversRates() { + for (const code of SUPPORTED_CURRENCIES) { + if (!CURRENCY_META[code]) { + throw new Error(`currencyPolicy: missing metadata for rate-listed currency ${code}`); + } + } + for (const code of Object.keys(CURRENCY_META)) { + if (!SUPPORTED_CURRENCIES.includes(code)) { + throw new Error(`currencyPolicy: metadata for unlisted currency ${code}`); + } + } +} + +assertPolicyCoversRates(); + +/** + * @param {*} code + * @returns {boolean} + */ +function isSupported(code) { + const normalized = currency.normalize(code); + return Object.prototype.hasOwnProperty.call(CURRENCY_META, normalized); +} + +/** + * @param {*} code + * @returns {CurrencyMeta} + */ +function getMeta(code) { + const normalized = currency.normalize(code); + const meta = CURRENCY_META[normalized]; + if (!meta) { + const err = new Error(`Unsupported currency: ${code == null || code === '' ? '(empty)' : code}`); + err.code = 'UNSUPPORTED_CURRENCY'; + err.currency = code; + throw err; + } + return meta; +} + +/** + * Effective max for a currency: per-currency override, else the global + * `config.maxTransferAmount` ceiling. + * @param {CurrencyMeta} meta + * @returns {number} + */ +function effectiveMax(meta) { + if (meta.maxAmount != null) return meta.maxAmount; + return config.maxTransferAmount; +} + +/** + * Largest magnitude that still fits in Number.MAX_SAFE_INTEGER once scaled + * to the currency's minor unit. + * @param {number} minorUnits + * @returns {number} + */ +function maxSafeMagnitude(minorUnits) { + return Number.MAX_SAFE_INTEGER / 10 ** minorUnits; +} + +/** + * Round `amount` to the currency's minor-unit precision. + * @param {number} amount + * @param {string} code + * @returns {number} + */ +function roundToCurrency(amount, code) { + const { minorUnits } = getMeta(code); + const factor = 10 ** minorUnits; + return Math.round((Number(amount) + Number.EPSILON) * factor) / factor; +} + +/** + * Validate and canonicalize a send amount for a currency. + * Returns `{ ok: true, amount, currency, meta }` or `{ ok: false, errors }`. + * + * @param {*} amount + * @param {*} code + * @param {{ enforceMax?: boolean }} [options] + * @returns {{ ok: true, amount: number, currency: string, meta: CurrencyMeta } | { ok: false, errors: string[] }} + */ +function canonicalizeAmount(amount, code, options = {}) { + const enforceMax = options.enforceMax !== false; + const errors = []; + + if (!code && code !== 0) { + errors.push('currency is required'); + return { ok: false, errors }; + } + + let meta; + try { + meta = getMeta(code); + } catch (err) { + errors.push(`Unsupported currency: ${code}`); + return { ok: false, errors }; + } + + if (amount === undefined || amount === null || amount === '') { + errors.push('amount is required'); + return { ok: false, errors }; + } + + const n = Number(amount); + if (!Number.isFinite(n) || n <= 0) { + errors.push('amount must be a positive number'); + return { ok: false, errors }; + } + + if (Math.abs(n) > maxSafeMagnitude(meta.minorUnits)) { + errors.push('amount is outside the supported numeric range'); + return { ok: false, errors }; + } + + if (!hasValidPrecisionFor(amount, meta.minorUnits)) { + if (meta.minorUnits === 0) { + errors.push(`amount for ${meta.code} must be a whole number`); + } else { + errors.push( + `amount must have at most ${meta.minorUnits} decimal places for ${meta.code}` + ); + } + return { ok: false, errors }; + } + + const canonical = roundToCurrency(n, meta.code); + + if (canonical < meta.minAmount) { + errors.push(`amount must be at least ${meta.minAmount} ${meta.code}`); + return { ok: false, errors }; + } + + if (enforceMax) { + const max = effectiveMax(meta); + if (canonical > max) { + errors.push(`amount must not exceed ${max}`); + return { ok: false, errors }; + } + } + + return { ok: true, amount: canonical, currency: meta.code, meta }; +} + +/** + * @param {*} value + * @param {number} decimals + * @returns {boolean} + */ +function hasValidPrecisionFor(value, decimals) { + if (typeof value !== 'number' && typeof value !== 'string') { + return false; + } + const str = String(value).trim(); + if (!/^-?\d+(\.\d+)?$/.test(str)) { + return false; + } + const fraction = str.split('.')[1] || ''; + return fraction.length <= decimals; +} + +/** + * Collect validation errors for a from/to transfer or quote pair. + * @param {*} amount + * @param {*} from + * @param {*} to + * @param {{ enforceMax?: boolean, amountLabel?: string }} [options] + * @returns {string[]} + */ +function validateTransferPair(amount, from, to, options = {}) { + const errors = []; + const amountLabel = options.amountLabel || 'amount'; + + if (!from) { + errors.push('from currency is required'); + } else if (!isSupported(from)) { + errors.push(`Unsupported source currency: ${from}`); + } + + if (!to) { + errors.push('to currency is required'); + } else if (!isSupported(to)) { + errors.push(`Unsupported target currency: ${to}`); + } + + if (from && to && currency.normalize(from) && currency.normalize(to) + && currency.normalize(from) === currency.normalize(to)) { + errors.push('from and to currencies must differ'); + } + + // Only canonicalize the send amount against the source currency once the + // source code itself is known/supported — otherwise we would double-report. + if (from && isSupported(from)) { + const result = canonicalizeAmount(amount, from, { + enforceMax: options.enforceMax, + }); + if (!result.ok) { + for (const e of result.errors) { + // Rewrite generic "currency is required" / bare amount messages with + // the caller's label when useful; keep specific currency messages. + if (e === 'amount is required') { + errors.push(`${amountLabel} is required`); + } else if (e === 'amount must be a positive number') { + errors.push(`${amountLabel} must be a positive number`); + } else if (e === 'amount is outside the supported numeric range') { + errors.push(`${amountLabel} is outside the supported numeric range`); + } else { + errors.push(e.replace(/^amount/, amountLabel)); + } + } + } + } else if (amount === undefined || amount === null || amount === '') { + errors.push(`${amountLabel} is required`); + } else if (!Number.isFinite(Number(amount)) || Number(amount) <= 0) { + // Surface a basic amount error even when the currency is missing so the + // client learns both problems in one round-trip. + const n = Number(amount); + if (!Number.isFinite(n) || n <= 0) { + errors.push(`${amountLabel} must be a positive number`); + } + } + + return errors; +} + +module.exports = { + CURRENCY_META, + isSupported, + getMeta, + effectiveMax, + maxSafeMagnitude, + roundToCurrency, + canonicalizeAmount, + hasValidPrecisionFor, + validateTransferPair, +}; diff --git a/src/utils/money.js b/src/utils/money.js index 04d3e53..9fbf86b 100644 --- a/src/utils/money.js +++ b/src/utils/money.js @@ -4,6 +4,10 @@ * Money helpers. * All amounts are treated as plain numbers but rounded to a fixed * number of decimal places to avoid floating point surprises. + * + * Prefer `currencyPolicy.roundToCurrency` / `canonicalizeAmount` at API and + * settlement boundaries; the helpers here remain for shared arithmetic and + * for callers that do not yet have a currency code. */ const DECIMALS = 2; @@ -16,10 +20,11 @@ const MAX_SAFE_AMOUNT = Number.MAX_SAFE_INTEGER / 10 ** DECIMALS; /** * Round a numeric amount to the configured number of decimals. * @param {number} amount + * @param {number} [decimals] * @returns {number} */ -function round(amount) { - const factor = 10 ** DECIMALS; +function round(amount, decimals = DECIMALS) { + const factor = 10 ** decimals; return Math.round((Number(amount) + Number.EPSILON) * factor) / factor; } @@ -85,26 +90,43 @@ function clamp(amount, min, max) { /** * Compute `percent` percent of `amount`, rounded to the money precision. + * When a currency code is supplied, rounding uses that currency's minor + * units via currencyPolicy (lazy-required to avoid a circular import at + * module load). * @param {number} amount * @param {number} percent - e.g. 1.5 for 1.5%. + * @param {string} [currencyCode] * @returns {number} */ -function percentage(amount, percent) { - return round(Number(amount) * (Number(percent) / 100)); +function percentage(amount, percent, currencyCode) { + const raw = Number(amount) * (Number(percent) / 100); + if (currencyCode) { + const currencyPolicy = require('./currencyPolicy'); + return currencyPolicy.roundToCurrency(raw, currencyCode); + } + return round(raw); } /** * Format an amount with its currency code, e.g. "10.00 USD". * @param {number} amount - * @param {string} currency + * @param {string} currencyCode * @returns {string} */ -function format(amount, currency) { - return `${round(amount).toFixed(DECIMALS)} ${currency}`; +function format(amount, currencyCode) { + try { + const currencyPolicy = require('./currencyPolicy'); + const meta = currencyPolicy.getMeta(currencyCode); + const rounded = currencyPolicy.roundToCurrency(amount, meta.code); + return `${rounded.toFixed(meta.minorUnits)} ${meta.code}`; + } catch { + return `${round(amount).toFixed(DECIMALS)} ${currencyCode}`; + } } module.exports = { DECIMALS, + MAX_SAFE_AMOUNT, round, isPositiveAmount, isSafeAmount, diff --git a/src/validators/quoteValidator.js b/src/validators/quoteValidator.js index d7817fe..af0cd7b 100644 --- a/src/validators/quoteValidator.js +++ b/src/validators/quoteValidator.js @@ -1,37 +1,22 @@ 'use strict'; -const money = require('../utils/money'); +const currencyPolicy = require('../utils/currencyPolicy'); /** * Validate query parameters for GET /api/quote. + * Uses the same currency policy as transfer creation so a preview that + * succeeds is always executable (and an unsupported pair fails before + * any FX math runs). * @param {import('express').Request} req * @returns {string[]} list of error messages. */ function validateQuoteQuery(req) { - const errors = []; const { amount, from, to } = req.query; - - if (amount === undefined) { - errors.push('amount is required'); - } else if (!money.isPositiveAmount(amount)) { - errors.push('amount must be a positive number'); - } else if (!money.isSafeAmount(amount)) { - errors.push('amount is outside the supported numeric range'); - } else if (!money.hasValidPrecision(amount)) { - errors.push(`amount must have at most ${money.DECIMALS} decimal places`); - } - - if (!from) { - errors.push('from currency is required'); - } - if (!to) { - errors.push('to currency is required'); - } - if (from && to && from === to) { - errors.push('from and to currencies must differ'); - } - - return errors; + // Quotes do not enforce the transfer max — they are informational — but + // they do enforce currency support, precision, and positive/safe range. + return currencyPolicy.validateTransferPair(amount, from, to, { + enforceMax: false, + }); } module.exports = { diff --git a/src/validators/transferValidator.js b/src/validators/transferValidator.js index 830f049..7e8efb7 100644 --- a/src/validators/transferValidator.js +++ b/src/validators/transferValidator.js @@ -1,10 +1,11 @@ 'use strict'; -const money = require('../utils/money'); -const config = require('../config'); +const currencyPolicy = require('../utils/currencyPolicy'); /** * Validate the body for POST /api/transfers. + * Amount and currency rules come from the shared currency policy so the + * HTTP layer rejects the same inputs that settlement would reject. * @param {import('express').Request} req * @returns {string[]} list of error messages. */ @@ -19,26 +20,10 @@ function validateCreateTransfer(req) { if (!recipientName || typeof recipientName !== 'string') { errors.push('recipientName is required'); } - if (amount === undefined) { - errors.push('amount is required'); - } else if (!money.isPositiveAmount(amount)) { - errors.push('amount must be a positive number'); - } else if (!money.isSafeAmount(amount)) { - errors.push('amount is outside the supported numeric range'); - } else if (!money.hasValidPrecision(amount)) { - errors.push(`amount must have at most ${money.DECIMALS} decimal places`); - } else if (Number(amount) > config.maxTransferAmount) { - errors.push(`amount must not exceed ${config.maxTransferAmount}`); - } - if (!from) { - errors.push('from currency is required'); - } - if (!to) { - errors.push('to currency is required'); - } - if (from && to && from === to) { - errors.push('from and to currencies must differ'); - } + + errors.push( + ...currencyPolicy.validateTransferPair(amount, from, to, { enforceMax: true }) + ); return errors; } diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js new file mode 100644 index 0000000..56e311d --- /dev/null +++ b/test/currencyPolicy.test.js @@ -0,0 +1,231 @@ +'use strict'; + +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; + +const currencyPolicy = require('../src/utils/currencyPolicy'); +const quoteService = require('../src/services/quoteService'); +const { validateCreateTransfer } = require('../src/validators/transferValidator'); +const { validateQuoteQuery } = require('../src/validators/quoteValidator'); +const createApp = require('../src/app'); +const { reset } = require('../src/store'); + +// ── Unit: policy table ────────────────────────────────────────────────────── + +test('CURRENCY_META covers every rate-listed currency with ISO minor units', () => { + assert.equal(currencyPolicy.getMeta('USD').minorUnits, 2); + assert.equal(currencyPolicy.getMeta('jpy').minorUnits, 0); + assert.equal(currencyPolicy.getMeta('JPY').minAmount, 1); + assert.equal(currencyPolicy.isSupported('MXN'), true); + assert.equal(currencyPolicy.isSupported('XYZ'), false); +}); + +test('canonicalizeAmount accepts a well-formed USD amount and returns canonical form', () => { + const result = currencyPolicy.canonicalizeAmount('100.50', 'usd'); + assert.equal(result.ok, true); + assert.equal(result.amount, 100.5); + assert.equal(result.currency, 'USD'); +}); + +test('canonicalizeAmount rejects unsupported currency before mutation', () => { + const result = currencyPolicy.canonicalizeAmount(10, 'ZZZ'); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /Unsupported currency/i.test(e))); +}); + +test('canonicalizeAmount rejects sub-minor-unit precision for USD', () => { + const result = currencyPolicy.canonicalizeAmount(10.129, 'USD'); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /decimal places/i.test(e))); +}); + +test('canonicalizeAmount rejects fractional JPY (zero-decimal currency)', () => { + const result = currencyPolicy.canonicalizeAmount(100.5, 'JPY'); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /whole number/i.test(e))); +}); + +test('canonicalizeAmount accepts whole JPY and rejects below minimum', () => { + assert.equal(currencyPolicy.canonicalizeAmount(100, 'JPY').ok, true); + const tooSmall = currencyPolicy.canonicalizeAmount(0.5, 'JPY'); + // 0.5 has a fractional digit so precision fails first; 0 is non-positive. + const zero = currencyPolicy.canonicalizeAmount(0, 'JPY'); + assert.equal(zero.ok, false); + assert.ok(tooSmall.ok === false); +}); + +test('canonicalizeAmount rejects overflow past safe minor-unit magnitude', () => { + const result = currencyPolicy.canonicalizeAmount(1e21, 'USD'); + assert.equal(result.ok, false); + assert.ok(result.errors.some((e) => /numeric range/i.test(e))); +}); + +test('canonicalizeAmount enforces the transfer max when requested', () => { + const over = currencyPolicy.canonicalizeAmount(999999, 'USD', { enforceMax: true }); + assert.equal(over.ok, false); + assert.ok(over.errors.some((e) => /must not exceed/i.test(e))); + const quoteOk = currencyPolicy.canonicalizeAmount(999999, 'USD', { enforceMax: false }); + assert.equal(quoteOk.ok, true); +}); + +test('roundToCurrency uses destination minor units', () => { + assert.equal(currencyPolicy.roundToCurrency(10.129, 'USD'), 10.13); + assert.equal(currencyPolicy.roundToCurrency(10.6, 'JPY'), 11); + assert.equal(currencyPolicy.roundToCurrency(10.4, 'JPY'), 10); +}); + +// ── Validators share the policy ───────────────────────────────────────────── + +test('validateCreateTransfer rejects unsupported currencies', () => { + const errors = validateCreateTransfer({ + body: { + senderName: 'A', + recipientName: 'B', + amount: 10, + from: 'USD', + to: 'ZZZ', + }, + }); + assert.ok(errors.some((e) => /Unsupported target currency/i.test(e))); +}); + +test('validateQuoteQuery rejects unsupported source currency', () => { + const errors = validateQuoteQuery({ + query: { amount: '10', from: 'AAA', to: 'USD' }, + }); + assert.ok(errors.some((e) => /Unsupported source currency/i.test(e))); +}); + +test('validateCreateTransfer rejects JPY fractional send amounts', () => { + const errors = validateCreateTransfer({ + body: { + senderName: 'A', + recipientName: 'B', + amount: 50.5, + from: 'JPY', + to: 'USD', + }, + }); + assert.ok(errors.some((e) => /whole number/i.test(e))); +}); + +// ── Preview matches execution ─────────────────────────────────────────────── + +test('quote preview sendAmount matches canonicalizeAmount for the same input', () => { + const amount = '250.25'; + const from = 'USD'; + const to = 'EUR'; + const canonical = currencyPolicy.canonicalizeAmount(amount, from); + const quote = quoteService.getQuote(amount, from, to); + assert.equal(canonical.ok, true); + assert.equal(quote.sendAmount, canonical.amount); + assert.equal(quote.from, 'USD'); + assert.equal(quote.to, 'EUR'); +}); + +test('JPY destination receiveAmount is a whole number', () => { + const quote = quoteService.getQuote(100, 'USD', 'JPY'); + assert.equal(Number.isInteger(quote.receiveAmount), true); +}); + +test('getQuote rejects unsupported currency before FX math', () => { + assert.throws( + () => quoteService.getQuote(10, 'USD', 'ZZZ'), + (err) => err.status === 400 || err.statusCode === 400 || /Unsupported/i.test(err.message) + ); +}); + +// ── HTTP contract ─────────────────────────────────────────────────────────── + +let server; +let baseUrl; + +before(() => { + const app = createApp(); + return new Promise((resolve) => { + server = app.listen(0, () => { + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); +}); + +after(() => { + if (server) server.close(); +}); + +beforeEach(() => { + reset(); +}); + +async function fetchJson(path, options = {}) { + const res = await fetch(`${baseUrl}${path}`, options); + const body = await res.json(); + return { status: res.status, body }; +} + +test('GET /api/quote rejects unsupported currency', async () => { + const { status, body } = await fetchJson('/api/quote?amount=10&from=USD&to=ZZZ'); + assert.equal(status, 400); + assert.ok( + body.error.details.errors.some((e) => /Unsupported target currency/i.test(e)) + ); +}); + +test('GET /api/quote rejects fractional JPY send amount', async () => { + const { status, body } = await fetchJson('/api/quote?amount=10.5&from=JPY&to=USD'); + assert.equal(status, 400); + assert.ok(body.error.details.errors.some((e) => /whole number/i.test(e))); +}); + +test('POST /api/transfers rejects unsupported currency before mutation', async () => { + const { status, body } = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-currency-1', + }, + body: JSON.stringify({ + senderName: 'Alice', + recipientName: 'Bob', + amount: 25, + from: 'USD', + to: 'ZZZ', + }), + }); + assert.equal(status, 400); + assert.ok( + body.error.details.errors.some((e) => /Unsupported target currency/i.test(e)) + ); +}); + +test('POST /api/transfers and GET /api/quote agree on canonical sendAmount', async () => { + const amount = 88.88; + const quoteRes = await fetchJson(`/api/quote?amount=${amount}&from=USD&to=EUR`); + assert.equal(quoteRes.status, 200); + + const transferRes = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-currency-preview-1', + }, + body: JSON.stringify({ + senderName: 'Alice', + recipientName: 'Bob', + amount, + from: 'USD', + to: 'EUR', + }), + }); + assert.equal(transferRes.status, 201); + assert.equal(transferRes.body.sendAmount, quoteRes.body.sendAmount); + assert.equal(transferRes.body.from, quoteRes.body.from); + assert.equal(transferRes.body.to, quoteRes.body.to); + assert.equal(transferRes.body.receiveAmount, quoteRes.body.receiveAmount); +}); From cb025ce3d6eafcc4fcf00a14a0453cf2a54d9de1 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Fri, 25 Sep 2026 21:14:47 -0400 Subject: [PATCH 2/4] fix(quote): enforce transfer amount ceiling in preview --- src/services/quoteService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/quoteService.js b/src/services/quoteService.js index d02c374..69f3864 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -37,7 +37,7 @@ function calculateFee(amount, fromCode = config.baseCurrency) { */ function getQuote(amount, from, to) { const canonical = currencyPolicy.canonicalizeAmount(amount, from, { - enforceMax: false, + enforceMax: true, }); if (!canonical.ok) { throw ApiError.badRequest(canonical.errors[0] || 'Invalid amount'); From 70b23f79306d7114938e8c63f0f211053c2fb13e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Fri, 25 Sep 2026 21:14:55 -0400 Subject: [PATCH 3/4] fix(quote): align validation ceiling with transfer --- src/validators/quoteValidator.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/validators/quoteValidator.js b/src/validators/quoteValidator.js index af0cd7b..bd7ceff 100644 --- a/src/validators/quoteValidator.js +++ b/src/validators/quoteValidator.js @@ -12,10 +12,9 @@ const currencyPolicy = require('../utils/currencyPolicy'); */ function validateQuoteQuery(req) { const { amount, from, to } = req.query; - // Quotes do not enforce the transfer max — they are informational — but - // they do enforce currency support, precision, and positive/safe range. + // A successful preview must also pass the transfer amount ceiling. return currencyPolicy.validateTransferPair(amount, from, to, { - enforceMax: false, + enforceMax: true, }); } From 3d073fc34783ad98bb5a7e11691aa403537b4894 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Fri, 25 Sep 2026 21:15:12 -0400 Subject: [PATCH 4/4] test(quote): cover over-ceiling preview and transfer agreement --- test/currencyPolicy.test.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js index 56e311d..21ae93f 100644 --- a/test/currencyPolicy.test.js +++ b/test/currencyPolicy.test.js @@ -175,6 +175,27 @@ test('GET /api/quote rejects unsupported currency', async () => { ); }); +test('GET /api/quote rejects a send amount beyond the transfer ceiling', async () => { + const amount = 999999; + const quote = await fetchJson(`/api/quote?amount=${amount}&from=USD&to=EUR`); + assert.equal(quote.status, 400); + assert.ok(quote.body.error.details.errors.some((e) => /must not exceed/i.test(e))); + + assert.ok(validateCreateTransfer({ + body: { + senderName: 'Alice', + recipientName: 'Bob', + amount, + from: 'USD', + to: 'EUR', + }, + }).some((e) => /must not exceed/i.test(e))); + assert.throws( + () => quoteService.getQuote(amount, 'USD', 'EUR'), + (err) => /must not exceed/i.test(err.message) + ); +}); + test('GET /api/quote rejects fractional JPY send amount', async () => { const { status, body } = await fetchJson('/api/quote?amount=10.5&from=JPY&to=USD'); assert.equal(status, 400);