From 23c33e603132434cae092a7b23e3eba3862ae7be Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:43:01 -0400 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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); From 1c52e9528de483db705cc50155bc5cce4f1ab736 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 03:11:13 -0400 Subject: [PATCH 05/17] fix(quote): round percentage and flat fees together Keep fee components unrounded until their sum reaches the shared currency policy. Compensate for floating-point drift at half-minor-unit boundaries so JPY fees include the flat component without regressing USD cent ties. Add literal boundary regressions and exercise the HTTP quote/transfer path for 30 JPY: fee 1 JPY and recipient amount 0.19 USD. Validation on Node 24.19.0: focused checks 53/53; npm test 289/289. --- src/services/quoteService.js | 10 ++++++-- test/currencyPolicy.test.js | 48 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/services/quoteService.js b/src/services/quoteService.js index 69f3864..cae5515 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -24,8 +24,14 @@ const ApiError = require('../utils/ApiError'); * @returns {number} */ function calculateFee(amount, fromCode = config.baseCurrency) { - const percentFee = money.percentage(amount, config.fee.percent, fromCode); - return currencyPolicy.roundToCurrency(percentFee + config.fee.flat, fromCode); + const percentFee = Number(amount) * (Number(config.fee.percent) / 100); + const fee = percentFee + config.fee.flat; + // Round both components together, compensating for binary drift at a + // half-minor-unit boundary (for example, 480 JPY yields 7.499999999999999). + return currencyPolicy.roundToCurrency( + fee + Math.abs(fee) * Number.EPSILON, + fromCode + ); } /** diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js index 21ae93f..4e4aa7a 100644 --- a/test/currencyPolicy.test.js +++ b/test/currencyPolicy.test.js @@ -130,6 +130,25 @@ test('JPY destination receiveAmount is a whole number', () => { assert.equal(Number.isInteger(quote.receiveAmount), true); }); +for (const [currency, amount, expectedFee] of [ + ['JPY', 13, 0], + ['JPY', 14, 1], + ['JPY', 30, 1], + ['JPY', 80, 2], + ['JPY', 479, 7], + ['JPY', 480, 8], + ['JPY', 481, 8], + ['USD', 100.50, 1.81], + ['USD', 116.99, 2.05], + ['USD', 117, 2.06], + ['USD', 117.01, 2.06], + ['USD', 125, 2.18], +]) { + test(`combined fee for ${amount} ${currency} rounds once to ${expectedFee}`, () => { + assert.equal(quoteService.calculateFee(amount, currency), expectedFee); + }); +} + test('getQuote rejects unsupported currency before FX math', () => { assert.throws( () => quoteService.getQuote(10, 'USD', 'ZZZ'), @@ -250,3 +269,32 @@ test('POST /api/transfers and GET /api/quote agree on canonical sendAmount', asy assert.equal(transferRes.body.to, quoteRes.body.to); assert.equal(transferRes.body.receiveAmount, quoteRes.body.receiveAmount); }); + +test('JPY quote and transfer use the fee rounded after both components are added', async () => { + const quoteRes = await fetchJson('/api/quote?amount=30&from=JPY&to=USD'); + assert.equal(quoteRes.status, 200); + assert.equal(quoteRes.body.sendAmount, 30); + assert.equal(quoteRes.body.fee, 1); + assert.equal(quoteRes.body.amountAfterFee, 29); + assert.equal(quoteRes.body.receiveAmount, 0.19); + + const transferRes = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-currency-jpy-fee', + }, + body: JSON.stringify({ + senderName: 'Alice', + recipientName: 'Bob', + amount: 30, + from: 'JPY', + to: 'USD', + }), + }); + assert.equal(transferRes.status, 201); + assert.equal(transferRes.body.sendAmount, 30); + assert.equal(transferRes.body.fee, 1); + assert.equal(transferRes.body.receiveAmount, 0.19); +}); From e79ead3a6f5db19fe0e2c157ba82b49c64b511ee Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 03:45:11 -0400 Subject: [PATCH 06/17] fix: reject transfers with nonpositive recipient amounts --- src/services/quoteService.js | 5 +++ test/currencyPolicy.test.js | 81 +++++++++++++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/services/quoteService.js b/src/services/quoteService.js index cae5515..f1a5e5e 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -74,6 +74,11 @@ function getQuote(amount, from, to) { amountAfterFee * rate, toCode ); + if (receiveAmount <= 0) { + throw ApiError.badRequest( + 'Amount must produce a positive receive amount after fees and currency rounding' + ); + } return { from: fromCode, diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js index 4e4aa7a..1c9e9af 100644 --- a/test/currencyPolicy.test.js +++ b/test/currencyPolicy.test.js @@ -7,10 +7,12 @@ process.env.NODE_ENV = 'test'; const currencyPolicy = require('../src/utils/currencyPolicy'); const quoteService = require('../src/services/quoteService'); +const stellarService = require('../src/services/stellarService'); +const auditService = require('../src/services/auditService'); const { validateCreateTransfer } = require('../src/validators/transferValidator'); const { validateQuoteQuery } = require('../src/validators/quoteValidator'); const createApp = require('../src/app'); -const { reset } = require('../src/store'); +const { store, reset } = require('../src/store'); // ── Unit: policy table ────────────────────────────────────────────────────── @@ -298,3 +300,80 @@ test('JPY quote and transfer use the fee rounded after both components are added assert.equal(transferRes.body.fee, 1); assert.equal(transferRes.body.receiveAmount, 0.19); }); + +for (const [amount, correctedAmount, from, to, receiveAmount] of [ + [0.01, 0.31, 'USD', 'EUR', 0.01], + [0.30, 0.31, 'USD', 'EUR', 0.01], + [8.11, 8.12, 'NGN', 'USD', 0.01], + [5.53, 5.54, 'NGN', 'JPY', 1], +]) { + test(`quote and transfer reject an unpayable ${amount} ${from} to ${to} before mutation`, async (t) => { + // Observe the existing settlement adapter without replacing its behavior. + const submitPayment = t.mock.method(stellarService, 'submitPayment'); + const createTransfer = (value) => fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-currency-positive-payout', + }, + body: JSON.stringify({ + senderName: 'Alice', recipientName: 'Bob', amount: value, from, to, + }), + }); + + const quote = await fetchJson(`/api/quote?amount=${amount}&from=${from}&to=${to}`); + const rejected = await createTransfer(amount); + assert.equal(quote.status, 400); + assert.equal(rejected.status, 400); + assert.match(quote.body.error.message, /positive receive amount/i); + assert.equal(rejected.body.error.message, quote.body.error.message); + assert.equal(submitPayment.mock.callCount(), 0); + assert.equal(store.transfers.size, 0); + assert.equal(store.transferIndex.size, 0); + assert.equal(auditService.countEntries(), 0); + assert.equal(store.idempotency.size, 0); + + // A corrected amount can reuse the rejected request's key. Amounts that + // round up to one destination minor unit remain valid. + const correctedQuote = await fetchJson( + `/api/quote?amount=${correctedAmount}&from=${from}&to=${to}` + ); + const corrected = await createTransfer(correctedAmount); + assert.equal(correctedQuote.status, 200); + assert.equal(corrected.status, 201); + assert.equal(correctedQuote.body.receiveAmount, receiveAmount); + assert.equal(corrected.body.receiveAmount, correctedQuote.body.receiveAmount); + assert.equal(submitPayment.mock.callCount(), 1); + assert.equal(store.transfers.size, 1); + assert.equal(store.transferIndex.size, 1); + assert.equal(auditService.countEntries(), 1); + assert.equal(store.idempotency.size, 1); + }); +} + +for (const [amount, from, to, fee, receiveAmount] of [ + [1, 'JPY', 'USD', 0, 0.01], + [50000, 'USD', 'NGN', 750.30, 75768769.23], +]) { + test(`positive payout preserves the source amount boundary ${amount} ${from} to ${to}`, async () => { + const quote = await fetchJson(`/api/quote?amount=${amount}&from=${from}&to=${to}`); + const transfer = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-currency-source-boundary', + }, + body: JSON.stringify({ + senderName: 'Alice', recipientName: 'Bob', amount, from, to, + }), + }); + assert.equal(quote.status, 200); + assert.equal(transfer.status, 201); + assert.equal(quote.body.fee, fee); + assert.equal(quote.body.receiveAmount, receiveAmount); + assert.equal(transfer.body.sendAmount, amount); + assert.equal(transfer.body.receiveAmount, quote.body.receiveAmount); + }); +} From 46c0e758036f511db527e0cec4edbded33f33d2c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 3 Oct 2026 08:35:03 -0400 Subject: [PATCH 07/17] fix(validation): reject structured amounts without object coercion Malformed JSON and parsed query objects could throw from Number() or an unsupported-currency error label when toString was not callable. Seven actual GET quote / POST transfer cases returned 500 instead of normal validation errors. Accept only numbers and strings for amount conversion, and describe invalid currency types without invoking their conversion properties. Use the shared currency label in policy, quote, and rate errors. Preserve the previous source ceiling, minor-unit, combined-fee, positive-payout, preview/execution, and idempotency behavior. Validation: npm test passed 301/301 with zero failures or skips. The existing currency-policy file passed 45/45; six new regressions fail on sole parent e79ead3a while its previous 39 checks pass. Four changed JavaScript files passed syntax checks and git diff --check passed. Node 24.19.0 reused retained dependencies matching all 76 lockfile package versions; no install or dependency change. Actual local Express HTTP replay on the documented mock FX/Stellar and in-memory store changes all seven malformed JSON/query cases from 500 to 400 with field validation errors and no transfer or retry-key records. Valid transfer 201 and ordinary invalid-amount 400 controls remain. Extended HTTP regressions observe zero settlement calls, transfer/index/audit entries and idempotency records before rejection, and a corrected same-key request then succeeds with the previewed amounts. No live payment or hosted Node 22 CI result is claimed. --- CHANGELOG.md | 5 ++ src/services/quoteService.js | 2 +- src/services/rateService.js | 6 +-- src/utils/currencyPolicy.js | 29 +++++++---- test/currencyPolicy.test.js | 99 ++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc6119..a943530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ When preparing a new release: ### Fixed +- Structured JSON or query values for amounts and currencies now produce + ordinary validation errors without invoking their conversion properties. + Invalid input remains rejected before settlement, transfer/audit insertion, + or idempotency reservation; valid numeric strings and currency codes retain + the shared precision, fee, and payout policy. - Offset pagination over transfer and audit history repeated or skipped rows when records were written while a client was paging, because the window was defined by a row count rather than a position. Cursor pagination anchors to diff --git a/src/services/quoteService.js b/src/services/quoteService.js index f1a5e5e..01097a8 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -50,7 +50,7 @@ function getQuote(amount, from, to) { } if (!currencyPolicy.isSupported(to)) { - throw ApiError.badRequest(`Unsupported target currency: ${to}`); + throw ApiError.badRequest(`Unsupported target currency: ${currencyPolicy.describeCurrency(to)}`); } const fromCode = canonical.currency; diff --git a/src/services/rateService.js b/src/services/rateService.js index 867f9c0..df4705a 100644 --- a/src/services/rateService.js +++ b/src/services/rateService.js @@ -3,6 +3,7 @@ const { RATES_TO_USD, SUPPORTED_CURRENCIES } = require('../config/rates'); const money = require('../utils/money'); const currency = require('../utils/currency'); +const currencyPolicy = require('../utils/currencyPolicy'); const ApiError = require('../utils/ApiError'); /** @@ -39,10 +40,10 @@ function getRate(from, to) { const fromCode = currency.normalize(from); const toCode = currency.normalize(to); if (!isSupported(fromCode)) { - throw ApiError.badRequest(`Unsupported source currency: ${from}`); + throw ApiError.badRequest(`Unsupported source currency: ${currencyPolicy.describeCurrency(from)}`); } if (!isSupported(toCode)) { - throw ApiError.badRequest(`Unsupported target currency: ${to}`); + throw ApiError.badRequest(`Unsupported target currency: ${currencyPolicy.describeCurrency(to)}`); } // Convert source -> USD -> target. return RATES_TO_USD[fromCode] / RATES_TO_USD[toCode]; @@ -57,7 +58,6 @@ function getRate(from, to) { */ function convert(amount, from, to) { const rate = getRate(from, to); - 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 index fe6ebdb..a49dfa6 100644 --- a/src/utils/currencyPolicy.js +++ b/src/utils/currencyPolicy.js @@ -64,6 +64,12 @@ function isSupported(code) { return Object.prototype.hasOwnProperty.call(CURRENCY_META, normalized); } +/** Describe invalid input without calling user-supplied conversion properties. */ +function describeCurrency(code) { + if (code == null || code === '') return '(empty)'; + return typeof code === 'string' ? code : '(invalid type)'; +} + /** * @param {*} code * @returns {CurrencyMeta} @@ -72,7 +78,7 @@ 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}`); + const err = new Error(`Unsupported currency: ${describeCurrency(code)}`); err.code = 'UNSUPPORTED_CURRENCY'; err.currency = code; throw err; @@ -113,6 +119,11 @@ function roundToCurrency(amount, code) { return Math.round((Number(amount) + Number.EPSILON) * factor) / factor; } +/** Amount input is numeric or textual; objects and arrays are never coerced. */ +function parseAmountNumber(amount) { + return typeof amount === 'number' || typeof amount === 'string' ? Number(amount) : NaN; +} + /** * Validate and canonicalize a send amount for a currency. * Returns `{ ok: true, amount, currency, meta }` or `{ ok: false, errors }`. @@ -135,7 +146,7 @@ function canonicalizeAmount(amount, code, options = {}) { try { meta = getMeta(code); } catch (err) { - errors.push(`Unsupported currency: ${code}`); + errors.push(`Unsupported currency: ${describeCurrency(code)}`); return { ok: false, errors }; } @@ -144,7 +155,7 @@ function canonicalizeAmount(amount, code, options = {}) { return { ok: false, errors }; } - const n = Number(amount); + const n = parseAmountNumber(amount); if (!Number.isFinite(n) || n <= 0) { errors.push('amount must be a positive number'); return { ok: false, errors }; @@ -216,13 +227,13 @@ function validateTransferPair(amount, from, to, options = {}) { if (!from) { errors.push('from currency is required'); } else if (!isSupported(from)) { - errors.push(`Unsupported source currency: ${from}`); + errors.push(`Unsupported source currency: ${describeCurrency(from)}`); } if (!to) { errors.push('to currency is required'); } else if (!isSupported(to)) { - errors.push(`Unsupported target currency: ${to}`); + errors.push(`Unsupported target currency: ${describeCurrency(to)}`); } if (from && to && currency.normalize(from) && currency.normalize(to) @@ -253,13 +264,10 @@ function validateTransferPair(amount, from, to, options = {}) { } } else if (amount === undefined || amount === null || amount === '') { errors.push(`${amountLabel} is required`); - } else if (!Number.isFinite(Number(amount)) || Number(amount) <= 0) { + } else if (!Number.isFinite(parseAmountNumber(amount)) || parseAmountNumber(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`); - } + errors.push(`${amountLabel} must be a positive number`); } return errors; @@ -268,6 +276,7 @@ function validateTransferPair(amount, from, to, options = {}) { module.exports = { CURRENCY_META, isSupported, + describeCurrency, getMeta, effectiveMax, maxSafeMagnitude, diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js index 1c9e9af..caecdaa 100644 --- a/test/currencyPolicy.test.js +++ b/test/currencyPolicy.test.js @@ -7,6 +7,7 @@ process.env.NODE_ENV = 'test'; const currencyPolicy = require('../src/utils/currencyPolicy'); const quoteService = require('../src/services/quoteService'); +const rateService = require('../src/services/rateService'); const stellarService = require('../src/services/stellarService'); const auditService = require('../src/services/auditService'); const { validateCreateTransfer } = require('../src/validators/transferValidator'); @@ -37,6 +38,41 @@ test('canonicalizeAmount rejects unsupported currency before mutation', () => { assert.ok(result.errors.some((e) => /Unsupported currency/i.test(e))); }); +test('policy rejects structured amounts without invoking conversion properties', () => { + for (const amount of [{ toString: null }, { valueOf: false, toString: [] }, [{ toString: 0 }]]) { + const canonical = currencyPolicy.canonicalizeAmount(amount, 'USD'); + assert.equal(canonical.ok, false); + assert.deepEqual(canonical.errors, ['amount must be a positive number']); + for (const from of [undefined, 'ZZZ']) { + const errors = currencyPolicy.validateTransferPair(amount, from, 'EUR'); + assert.equal(errors.length, 2); + assert.ok(errors.includes('amount must be a positive number')); + } + } +}); + +test('structured currency codes produce policy errors and service 400s without coercion', () => { + for (const code of [{ toString: null }, { valueOf: false, toString: [] }, [{ toString: 0 }]]) { + assert.equal(currencyPolicy.isSupported(code), false); + assert.throws(() => currencyPolicy.getMeta(code), (err) => err.code === 'UNSUPPORTED_CURRENCY'); + assert.equal(currencyPolicy.canonicalizeAmount(100, code).ok, false); + assert.deepEqual(currencyPolicy.validateTransferPair(100, code, 'EUR'), [ + 'Unsupported source currency: (invalid type)', + ]); + assert.deepEqual(currencyPolicy.validateTransferPair(100, 'USD', code), [ + 'Unsupported target currency: (invalid type)', + ]); + for (const getQuote of [ + () => quoteService.getQuote(100, code, 'EUR'), + () => quoteService.getQuote(100, 'USD', code), + () => rateService.getRate(code, 'EUR'), + () => rateService.getRate('USD', code), + ]) { + assert.throws(getQuote, (err) => err.statusCode === 400 && /Unsupported/.test(err.message)); + } + } +}); + test('canonicalizeAmount rejects sub-minor-unit precision for USD', () => { const result = currencyPolicy.canonicalizeAmount(10.129, 'USD'); assert.equal(result.ok, false); @@ -188,6 +224,69 @@ async function fetchJson(path, options = {}) { return { status: res.status, body }; } +for (const field of ['amount', 'from', 'to']) { + test(`HTTP ${field} objects return 400 before effects and leave the request key reusable`, async (t) => { + const submitPayment = t.mock.method(stellarService, 'submitPayment'); + const valid = { senderName: 'Alice', recipientName: 'Bob', amount: 100, from: 'USD', to: 'EUR' }; + const post = (body) => fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': `idem-currency-typed-${field}`, + }, + body: JSON.stringify(body), + }); + for (const value of [{ toString: null }, { valueOf: false, toString: [] }, [{ toString: 0 }]]) { + const rejected = await post({ ...valid, [field]: value }); + assert.equal(rejected.status, 400); + assert.equal(rejected.body.error.message, 'Validation failed'); + assert.ok(rejected.body.error.details.errors.length > 0); + } + const query = new URLSearchParams({ amount: '100', from: 'USD', to: 'EUR' }); + query.delete(field); + query.set(`${field}[toString]`, 'not-callable'); + const quote = await fetchJson(`/api/quote?${query}`); + assert.equal(quote.status, 400); + assert.equal(quote.body.error.message, 'Validation failed'); + assert.ok(quote.body.error.details.errors.length > 0); + assert.equal(submitPayment.mock.callCount(), 0); + assert.equal(store.transfers.size, 0); + assert.equal(store.transferIndex.size, 0); + assert.equal(auditService.countEntries(), 0); + assert.equal(store.idempotency.size, 0); + + const preview = await fetchJson('/api/quote?amount=100&from=USD&to=EUR'); + const recovered = await post(valid); + assert.equal(preview.status, 200); + assert.equal(recovered.status, 201); + assert.equal(recovered.body.sendAmount, preview.body.sendAmount); + assert.equal(recovered.body.receiveAmount, preview.body.receiveAmount); + assert.equal(submitPayment.mock.callCount(), 1); + assert.equal(store.transfers.size, 1); + assert.equal(store.idempotency.size, 1); + }); +} + +test('invalid amount with a missing or unsupported source returns both HTTP validation errors', async () => { + for (const from of [undefined, 'ZZZ']) { + const response = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem-currency-two-errors', + }, + body: JSON.stringify({ senderName: 'Alice', recipientName: 'Bob', amount: { toString: null }, from, to: 'EUR' }), + }); + assert.equal(response.status, 400); + assert.equal(response.body.error.details.errors.length, 2); + assert.ok(response.body.error.details.errors.includes('amount must be a positive number')); + assert.equal(store.transfers.size, 0); + assert.equal(store.idempotency.size, 0); + } +}); + 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); From cf24ce967a3896e2d9f75c77ecb691da59bcac6e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 00:32:11 -0400 Subject: [PATCH 08/17] docs: describe the shared quote and transfer currency policy Replace the README's all-currencies two-decimal instruction with the implemented JPY and two-decimal currency rules. Document the shared source-amount ceiling and the post-fee, destination-rounded payout check. README only; existing implementation, tests and historical validation remain unchanged. Checked against current source and existing cases; no runtime or maintained suite replayed for this documentation update. --- README.md | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4791414..44cb77f 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The application is configured using environment variables (typically defined in | `DEFAULT_BASE_CURRENCY` | Default base currency for rates | `USD` | | `TRANSFER_FEE_PERCENT` | Percentage fee charged per transfer | `1.5` | | `TRANSFER_FEE_FLAT` | Flat fee charged per transfer | `0.30` | -| `MAX_TRANSFER_AMOUNT` | Maximum single transfer amount accepted | `50000` | +| `MAX_TRANSFER_AMOUNT` | Maximum send amount in source-currency units, shared by quotes and transfers | `50000` | | `STELLAR_NETWORK` | Stellar network environment (`testnet`, `public`) | `testnet` | | `CORS_ORIGIN` | Allowed CORS origin | `*` | | `RATE_LIMIT_WINDOW_MS` | Time window for rate limiting (ms) | `60000` | @@ -238,11 +238,43 @@ The API implements Cache-Control response headers for security and efficiency: - `GET /api/rates/:pair` — rate for one pair, e.g. `/api/rates/USD-INR`. - `GET /api/quote?amount=&from=&to=` — FX quote with fee breakdown. -All `amount` fields (quotes and transfers) are guarded for numeric -precision: values must be finite, within a safe numeric range, and have -at most 2 decimal places (e.g. `100.129` is rejected with a 400). This -prevents floating-point/sub-cent precision loss from being silently -rounded away. +Quote and transfer requests use the same +[currency policy](src/utils/currencyPolicy.js). Supply `amount` as a number +or an ordinary decimal string, and `from` / `to` as supported currency-code +strings. Codes are trimmed and normalized to uppercase; source and destination +must differ. Arrays and objects are rejected rather than coerced to amounts +or currency codes. + +Send amounts must be positive, finite, within the safe numeric range after +scaling to minor units, and satisfy the source currency's precision: + +| Source currency | Maximum decimal places | Minimum send amount | +| --- | --- | --- | +| `JPY` | `0` (whole units) | `1` | +| `USD`, `EUR`, `GBP`, `INR`, `NGN`, `PHP`, `MXN`, `KES` | `2` | `0.01` | + +For example, `amount=10.5&from=JPY&to=USD` is rejected with `400`, as is +`100.129` sent in USD. String amounts must use plain decimal notation; +fractional digits count even when they are zero, so `"100.0"` is not a valid +JPY string amount. Invalid send precision is rejected, not silently rounded. + +Both `GET /api/quote` and `POST /api/transfers` enforce +`MAX_TRANSFER_AMOUNT` (default `50000`) in the **source currency's units**. +This is not a USD-equivalent ceiling or a separate maximum on the converted +recipient amount. Configure it before starting the process; the shared +configuration is loaded at startup. + +The [quote service](src/services/quoteService.js) adds the percentage and flat +fee components, then rounds the combined fee once to source minor units. +The amount remaining after fees is converted and rounded to destination +minor units, including whole JPY payouts. The final receive amount must be +positive: meeting the table's minimum alone does not guarantee a payable +quote. A zero or negative payout after fees and destination rounding is +rejected with `400` by preview and transfer creation, before settlement or +transfer insertion. + +The demo retains JavaScript-number arithmetic and mock FX/Stellar services; +numeric strings do not add arbitrary-precision decimal accounting. ### Transfers From 8f4bd08e8e4320777c8569f7d3c8f63e19f4bf9c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 04:09:30 -0400 Subject: [PATCH 09/17] fix: preserve decimal factors through currency rounding Use exact decimal ratios for fees, net amounts and FX conversion before rounding once to currency minor units. Keep numeric API results and the existing negative-tie direction. Add focused midpoint and preview/transfer regressions to the existing currency policy tests. Native conversion, quote, createTransfer and stored transfer reproduction: 16.04 GBP -> USD: 19.68 -> 19.69 4.14 GBP -> EUR: 4.44 -> 4.45 384 JPY -> EUR: 2.34 -> 2.35 Nine tie/sign controls, twelve fee cases and six existing boundary/rejection controls pass. Real business/store/idempotency/audit modules ran against the repository mock Stellar adapter. No live payment was attempted. Node 24.19.0 with documented default configuration; dotenv loading was a declared no-op and UUID came from the existing runtime. The maintained HTTP file is extended but unexecuted here because locked Express 4/dotenv 16 dependencies are unavailable. No full-suite or hosted CI result is claimed. --- README.md | 10 +++++-- src/services/quoteService.js | 23 +++++++------- src/services/rateService.js | 8 +++-- src/utils/currencyPolicy.js | 58 ++++++++++++++++++++++++++++++++---- src/utils/money.js | 8 +++-- test/currencyPolicy.test.js | 48 +++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 44cb77f..799d5c7 100644 --- a/README.md +++ b/README.md @@ -273,8 +273,14 @@ quote. A zero or negative payout after fees and destination rounding is rejected with `400` by preview and transfer creation, before settlement or transfer insertion. -The demo retains JavaScript-number arithmetic and mock FX/Stellar services; -numeric strings do not add arbitrary-precision decimal accounting. +Fee calculation and FX conversion retain their decimal inputs until one +rounding step to the destination minor units. Nonnegative funds round half up; +negative intermediate values keep the existing ties-toward-positive-infinity +behavior. For example, a `16.04 GBP` send has a `0.54 GBP` fee and a +`19.69 USD` payout at the mock `1.27` rate (`15.50 × 1.27 = 19.685`). + +API amounts remain JavaScript numbers and the FX/Stellar services remain +mocks. Exact decimal intermediates do not change the external amount format. ### Transfers diff --git a/src/services/quoteService.js b/src/services/quoteService.js index 01097a8..6647ce7 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -24,14 +24,11 @@ const ApiError = require('../utils/ApiError'); * @returns {number} */ function calculateFee(amount, fromCode = config.baseCurrency) { - const percentFee = Number(amount) * (Number(config.fee.percent) / 100); - const fee = percentFee + config.fee.flat; - // Round both components together, compensating for binary drift at a - // half-minor-unit boundary (for example, 480 JPY yields 7.499999999999999). - return currencyPolicy.roundToCurrency( - fee + Math.abs(fee) * Number.EPSILON, - fromCode - ); + return currencyPolicy.roundToCurrency(amount, fromCode, { + multiplier: config.fee.percent, + divisor: 100, + addend: config.fee.flat, + }); } /** @@ -64,14 +61,16 @@ function getQuote(amount, from, to) { const fee = calculateFee(numericAmount, fromCode); const amountAfterFee = currencyPolicy.roundToCurrency( - numericAmount - fee, - fromCode + numericAmount, + fromCode, + { addend: -fee } ); const rate = rateService.getRate(fromCode, toCode); // 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, + const receiveAmount = rateService.convert( + amountAfterFee, + fromCode, toCode ); if (receiveAmount <= 0) { diff --git a/src/services/rateService.js b/src/services/rateService.js index df4705a..123267b 100644 --- a/src/services/rateService.js +++ b/src/services/rateService.js @@ -57,9 +57,13 @@ function getRate(from, to) { * @returns {number} */ function convert(amount, from, to) { - const rate = getRate(from, to); + getRate(from, to); // Preserve unsupported-currency validation. + const fromCode = currency.normalize(from); const toCode = currency.normalize(to); - return currencyPolicy.roundToCurrency(amount * rate, toCode); + return currencyPolicy.roundToCurrency(amount, toCode, { + multiplier: RATES_TO_USD[fromCode], + divisor: RATES_TO_USD[toCode], + }); } /** diff --git a/src/utils/currencyPolicy.js b/src/utils/currencyPolicy.js index a49dfa6..e8ff338 100644 --- a/src/utils/currencyPolicy.js +++ b/src/utils/currencyPolicy.js @@ -107,16 +107,64 @@ function maxSafeMagnitude(minorUnits) { return Number.MAX_SAFE_INTEGER / 10 ** minorUnits; } +/** Convert a finite decimal input to an exact numerator/denominator pair. */ +function decimalRatio(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + throw new RangeError('amount is outside the supported numeric range'); + } + if (numeric === 0) return [0n, 1n]; + const text = typeof value === 'string' ? value.trim() : String(numeric); + const match = /^([+-]?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(text); + if (!match) { + throw new TypeError('amount must be a decimal number'); + } + const fraction = match[3] || ''; + const scale = fraction.length - Number(match[4] || 0); + const numerator = BigInt(`${match[1]}${match[2]}${fraction}`); + return scale >= 0 + ? [numerator, 10n ** BigInt(scale)] + : [numerator * 10n ** BigInt(-scale), 1n]; +} + /** - * Round `amount` to the currency's minor-unit precision. - * @param {number} amount + * Round a decimal amount to minor units, with ties toward positive infinity + * (HALF_UP for nonnegative funds, preserving Math.round's negative ties). + * Fee and FX callers pass their original decimal factors so multiplication, + * division and addition happen before the single rounding operation. + * @param {number|string} amount * @param {string} code + * @param {{ multiplier?: number, divisor?: number, addend?: number }} [options] * @returns {number} */ -function roundToCurrency(amount, code) { +function roundToCurrency(amount, code, options = {}) { const { minorUnits } = getMeta(code); const factor = 10 ** minorUnits; - return Math.round((Number(amount) + Number.EPSILON) * factor) / factor; + const [amountN, amountD] = decimalRatio(amount); + const [multiplyN, multiplyD] = decimalRatio(options.multiplier ?? 1); + const [divideN, divideD] = decimalRatio(options.divisor ?? 1); + const [addN, addD] = decimalRatio(options.addend ?? 0); + if (divideN === 0n) { + throw new RangeError('currency conversion divisor must not be zero'); + } + let denominator = amountD * multiplyD * divideN * addD; + let numerator = (amountN * multiplyN * divideD * addD + + addN * amountD * multiplyD * divideN) * BigInt(factor); + if (denominator < 0n) { + numerator = -numerator; + denominator = -denominator; + } + const negative = numerator < 0n; + const magnitude = negative ? -numerator : numerator; + let rounded = magnitude / denominator; + const twiceRemainder = (magnitude % denominator) * 2n; + if (negative ? twiceRemainder > denominator : twiceRemainder >= denominator) { + rounded += 1n; + } + if (rounded > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new RangeError('amount is outside the supported numeric range'); + } + return (negative ? -Number(rounded) : Number(rounded)) / factor; } /** Amount input is numeric or textual; objects and arrays are never coerced. */ @@ -177,7 +225,7 @@ function canonicalizeAmount(amount, code, options = {}) { return { ok: false, errors }; } - const canonical = roundToCurrency(n, meta.code); + const canonical = roundToCurrency(amount, meta.code); if (canonical < meta.minAmount) { errors.push(`amount must be at least ${meta.minAmount} ${meta.code}`); diff --git a/src/utils/money.js b/src/utils/money.js index 9fbf86b..d1ed1ff 100644 --- a/src/utils/money.js +++ b/src/utils/money.js @@ -99,12 +99,14 @@ function clamp(amount, min, max) { * @returns {number} */ function percentage(amount, percent, currencyCode) { - const raw = Number(amount) * (Number(percent) / 100); if (currencyCode) { const currencyPolicy = require('./currencyPolicy'); - return currencyPolicy.roundToCurrency(raw, currencyCode); + return currencyPolicy.roundToCurrency(amount, currencyCode, { + multiplier: percent, + divisor: 100, + }); } - return round(raw); + return round(Number(amount) * (Number(percent) / 100)); } /** diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js index caecdaa..a0cc505 100644 --- a/test/currencyPolicy.test.js +++ b/test/currencyPolicy.test.js @@ -14,6 +14,7 @@ const { validateCreateTransfer } = require('../src/validators/transferValidator' const { validateQuoteQuery } = require('../src/validators/quoteValidator'); const createApp = require('../src/app'); const { store, reset } = require('../src/store'); +const money = require('../src/utils/money'); // ── Unit: policy table ────────────────────────────────────────────────────── @@ -114,6 +115,23 @@ test('roundToCurrency uses destination minor units', () => { assert.equal(currencyPolicy.roundToCurrency(10.4, 'JPY'), 10); }); +test('decimal rounding distinguishes exact ties from genuinely lower amounts', () => { + for (const [amount, code, expected] of [ + ['10.075', 'USD', 10.08], + ['19.685', 'USD', 19.69], + ['19.684999999999999', 'USD', 19.68], + ['19.685000000000001', 'USD', 19.69], + ['-10.075', 'USD', -10.07], + ['-10.075000000000001', 'USD', -10.08], + ['2.5', 'JPY', 3], + ['-2.5', 'JPY', -2], + ['-2.500000000000001', 'JPY', -3], + ]) { + assert.equal(currencyPolicy.roundToCurrency(amount, code), expected); + } + assert.equal(money.percentage(403, 2.5, 'USD'), 10.08); +}); + // ── Validators share the policy ───────────────────────────────────────────── test('validateCreateTransfer rejects unsupported currencies', () => { @@ -224,6 +242,36 @@ async function fetchJson(path, options = {}) { return { status: res.status, body }; } +for (const [amount, from, to, fee, afterFee, receiveAmount] of [ + [16.04, 'GBP', 'USD', 0.54, 15.5, 19.69], + [4.14, 'GBP', 'EUR', 0.36, 3.78, 4.45], + [384, 'JPY', 'EUR', 6, 378, 2.35], +]) { + test(`decimal FX rounding agrees for conversion, preview and transfer: ${from}/${to}`, async () => { + assert.equal(rateService.convert(afterFee, from, to), receiveAmount); + const quote = await fetchJson(`/api/quote?amount=${amount}&from=${from}&to=${to}`); + const transfer = await fetchJson('/api/transfers', { + method: 'POST', + headers: { + Authorization: 'Bearer test-token-admin', + 'Content-Type': 'application/json', + 'Idempotency-Key': `idem-decimal-fx-${from}-${to}`, + }, + body: JSON.stringify({ + senderName: 'Alice', recipientName: 'Bob', amount, from, to, + }), + }); + assert.equal(quote.status, 200); + assert.equal(transfer.status, 201); + assert.equal(quote.body.fee, fee); + assert.equal(quote.body.amountAfterFee, afterFee); + assert.equal(quote.body.receiveAmount, receiveAmount); + assert.equal(transfer.body.fee, fee); + assert.equal(transfer.body.sendAmount, amount); + assert.equal(transfer.body.receiveAmount, receiveAmount); + }); +} + for (const field of ['amount', 'from', 'to']) { test(`HTTP ${field} objects return 400 before effects and leave the request key reusable`, async (t) => { const submitPayment = t.mock.method(stellarService, 'submitPayment'); From 2e815f63d2f9f8fd2bbcb714fbad1b389877f2d5 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 04:24:24 -0400 Subject: [PATCH 10/17] docs: record reproducible currency policy checks Link the focused Node 22 run against the immutable rounding source. All 49 policy cases passed, including the HTTP quote/transfer regressions. Document the command and limit this evidence to the focused test file. --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 799d5c7..55fca35 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,19 @@ behavior. For example, a `16.04 GBP` send has a `0.54 GBP` fee and a API amounts remain JavaScript numbers and the FX/Stellar services remain mocks. Exact decimal intermediates do not change the external amount format. +Run the focused policy regressions after installing locked dependencies: + +```bash +npm ci --no-audit --no-fund +node --test test/currencyPolicy.test.js +``` + +The [recorded Node.js 22 run](https://github.com/woahwhattheheck/RemitFlow-Backend/actions/runs/37188320218) +checked out [`8f4bd08e`](https://github.com/woahwhattheheck/RemitFlow-Backend/commit/8f4bd08e8e4320777c8569f7d3c8f63e19f4bf9c) +and passed all 49 cases with no failures or skips, including decimal ties and +HTTP preview/transfer agreement for GBP/USD, GBP/EUR, and JPY/EUR. This is the +focused policy test file; it does not represent a complete-suite run. + ### Transfers - `POST /api/transfers` — create a transfer. From d31a56e42c0bfa8d62ab36fae4a68beab290b740 Mon Sep 17 00:00:00 2001 From: woahwhattheheck <293286387+woahwhattheheck@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:20:06 +0000 Subject: [PATCH 11/17] fix(currency): reject lossy numeric minor-unit output [skip ci] --- README.md | 15 ++++++++++++ src/utils/currencyPolicy.js | 19 +++++++++++++-- test/currencyPolicy.test.js | 48 +++++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55fca35..fe367c9 100644 --- a/README.md +++ b/README.md @@ -405,3 +405,18 @@ curl -H "Authorization: Bearer $TOKEN" "http://localhost:3000/api/audit" curl -H "Authorization: Bearer $TOKEN" \ "http://localhost:3000/api/audit?resourceId=&limit=20" ``` + + +### Exact numeric amount boundary + +The currency policy rejects an amount when its rounded minor-unit integer cannot +round-trip through the existing Number-valued JSON API without changing its +decimal value. `Number.MAX_SAFE_INTEGER` minor units is an upper bound, not a +promise that every smaller cent amount is representable. For example, +`90071992547409.91` USD would serialize as `90071992547409.9`; it now produces the +existing numeric-range validation error rather than silently losing a cent. +Representable neighboring values and whole-number JPY retain their existing +behavior. Direct rounding throws `RangeError`; canonical and HTTP validation +retain structured errors. No string-valued API migration is introduced. + +Focused coverage: `node --test test/currencyPolicy.test.js`. diff --git a/src/utils/currencyPolicy.js b/src/utils/currencyPolicy.js index e8ff338..6548862 100644 --- a/src/utils/currencyPolicy.js +++ b/src/utils/currencyPolicy.js @@ -164,7 +164,15 @@ function roundToCurrency(amount, code, options = {}) { if (rounded > BigInt(Number.MAX_SAFE_INTEGER)) { throw new RangeError('amount is outside the supported numeric range'); } - return (negative ? -Number(rounded) : Number(rounded)) / factor; + const result = (negative ? -Number(rounded) : Number(rounded)) / factor; + // A safe integer number of cents need not survive division as a Number. + // Compare its decimal serialization exactly, not another float multiply. + const [readbackN, readbackD] = decimalRatio(result); + const signedUnits = negative ? -rounded : rounded; + if (readbackN * BigInt(factor) !== signedUnits * readbackD) { + throw new RangeError('amount is outside the supported numeric range'); + } + return result; } /** Amount input is numeric or textual; objects and arrays are never coerced. */ @@ -225,7 +233,14 @@ function canonicalizeAmount(amount, code, options = {}) { return { ok: false, errors }; } - const canonical = roundToCurrency(amount, meta.code); + let canonical; + try { + canonical = roundToCurrency(amount, meta.code); + } catch (err) { + if (!(err instanceof RangeError)) throw err; + errors.push('amount is outside the supported numeric range'); + return { ok: false, errors }; + } if (canonical < meta.minAmount) { errors.push(`amount must be at least ${meta.minAmount} ${meta.code}`); diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js index a0cc505..f9a18f2 100644 --- a/test/currencyPolicy.test.js +++ b/test/currencyPolicy.test.js @@ -524,3 +524,51 @@ for (const [amount, from, to, fee, receiveAmount] of [ assert.equal(transfer.body.receiveAmount, quote.body.receiveAmount); }); } + + +// Decimal cents must survive the existing Number-valued API representation. +test('numeric readback rejects cent values rounded by the Number representation', () => { + for (const code of ['USD', 'EUR', 'GBP', 'INR', 'NGN', 'PHP', 'MXN', 'KES']) { + for (const amount of ['90071992547409.91', '70368744177664.01', '-70368744177664.01']) { + assert.throws(() => currencyPolicy.roundToCurrency(amount, code), + (error) => error instanceof RangeError && /numeric range/.test(error.message)); + } + } +}); + +test('numeric readback returns structured canonical errors without throwing', () => { + for (const amount of ['90071992547409.91', '70368744177664.01']) { + assert.deepEqual(currencyPolicy.canonicalizeAmount(amount, 'USD', { enforceMax: false }), { + ok: false, errors: ['amount is outside the supported numeric range'], + }); + assert.deepEqual(currencyPolicy.validateTransferPair(amount, 'USD', 'EUR', { enforceMax: false }), + ['amount is outside the supported numeric range']); + } +}); + +test('numeric readback retains representable decimal and whole-yen boundaries', () => { + for (const amount of ['90071992547409.89', '90071992547409.88', '90071992547409.90', '0.01', '1.01']) { + const result = currencyPolicy.canonicalizeAmount(amount, 'USD', { enforceMax: false }); + assert.equal(result.ok, true); + const text = JSON.parse(JSON.stringify(result.amount)).toString(); + const [whole, fraction = ''] = text.split('.'); + const cents = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')); + assert.equal(cents, BigInt(amount.replace('.', ''))); + } + assert.equal(currencyPolicy.roundToCurrency(Number.MAX_SAFE_INTEGER, 'JPY'), Number.MAX_SAFE_INTEGER); + assert.equal(currencyPolicy.roundToCurrency(-Number.MAX_SAFE_INTEGER, 'JPY'), -Number.MAX_SAFE_INTEGER); + assert.ok(Object.is(currencyPolicy.roundToCurrency('-0.001', 'USD'), -0)); +}); + +test('numeric readback returns HTTP 400 before transfer or audit effects', async (t) => { + const submitPayment = t.mock.method(stellarService, 'submitPayment'); + for (const amount of ['90071992547409.91', '70368744177664.01']) { + const response = await fetchJson(`/api/quote?amount=${amount}&from=USD&to=EUR`); + assert.equal(response.status, 400); + assert.ok(response.body.error.details.errors.includes('amount is outside the supported numeric range')); + } + assert.equal(submitPayment.mock.callCount(), 0); + assert.equal(store.transfers.size, 0); + assert.equal(store.idempotency.size, 0); + assert.equal(auditService.countEntries(), 0); +}); From 452a512d8ae1d739336dcb8e4bd45e17300e0597 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:20:25 -0400 Subject: [PATCH 12/17] fix(config): honor zero transfer fee components Explicit TRANSFER_FEE_PERCENT=0 or TRANSFER_FEE_FLAT=0 was treated as missing and replaced with the normal 1.5 percent / 0.30 defaults. Parse the fee components with a NaN fallback so individual or complete fee waivers reach the existing canonical quote calculation. Other numeric configuration and decimal rounding are unchanged. Extend the maintained config test file with default, blank, invalid, zero-percent, zero-flat, both-zero and custom-fee quote cases. The same 11 cases on Node 24.19 gave 8 pass / 3 fail against the previous source and 11 pass / 0 fail with this change. Local execution supplied process.env and replaced only dotenv's optional .env loading; configuration, currency and quote modules executed as published. No full HTTP suite, locked dependency run or current CI success is claimed. With both components zero, a 100 USD to EUR quote now has fee 0 and receiveAmount 92.59, instead of fee 1.80 and receiveAmount 90.93. Refs RemitFlow/RemitFlow-Backend#130 --- src/config/index.js | 11 +++++++++-- test/config.test.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/config/index.js b/src/config/index.js index 41f9a71..aec035a 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -2,6 +2,13 @@ require('dotenv').config(); +// An explicit zero disables a fee component; only an unparseable value uses +// its default. Keep the existing parsing behavior for other numeric settings. +function parseFee(value, fallback) { + const parsed = parseFloat(value); + return Number.isNaN(parsed) ? fallback : parsed; +} + /** * Centralized application configuration. * Values are read from environment variables with sensible defaults @@ -14,8 +21,8 @@ const config = { baseCurrency: process.env.DEFAULT_BASE_CURRENCY || 'USD', fee: { - percent: parseFloat(process.env.TRANSFER_FEE_PERCENT) || 1.5, - flat: parseFloat(process.env.TRANSFER_FEE_FLAT) || 0.3, + percent: parseFee(process.env.TRANSFER_FEE_PERCENT, 1.5), + flat: parseFee(process.env.TRANSFER_FEE_FLAT, 0.3), }, // Largest single transfer amount accepted (in the source currency). diff --git a/test/config.test.js b/test/config.test.js index 45c5d39..0491e17 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -86,3 +86,43 @@ test('config respects cache environment variables', () => { } }); +for (const [label, percent, flat, expectedPercent, expectedFlat, fee, receiveAmount] of [ + ['default fees', undefined, undefined, 1.5, 0.3, 1.8, 90.93], + ['blank fees', '', '', 1.5, 0.3, 1.8, 90.93], + ['unparseable fees', 'invalid', 'invalid', 1.5, 0.3, 1.8, 90.93], + ['zero percentage fee', '0', undefined, 0, 0.3, 0.3, 92.31], + ['zero flat fee', undefined, '0.00', 1.5, 0, 1.5, 91.2], + ['both fees waived', '0', '0', 0, 0, 0, 92.59], + ['custom fees', '2.5', '0.25', 2.5, 0.25, 2.75, 90.05], +]) { + test(`config and quote honor ${label}`, () => { + const values = { TRANSFER_FEE_PERCENT: percent, TRANSFER_FEE_FLAT: flat }; + const originalEnv = Object.fromEntries(Object.keys(values).map(key => [key, process.env[key]])); + const clearFeeModules = () => { + for (const path of [ + '../src/config', '../src/utils/currencyPolicy', + '../src/services/rateService', '../src/services/quoteService', + ]) delete require.cache[require.resolve(path)]; + }; + try { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearFeeModules(); + const config = require('../src/config'); + const quote = require('../src/services/quoteService').getQuote(100, 'USD', 'EUR'); + assert.equal(config.fee.percent, expectedPercent); + assert.equal(config.fee.flat, expectedFlat); + assert.equal(quote.fee, fee); + assert.equal(quote.amountAfterFee, 100 - fee); + assert.equal(quote.receiveAmount, receiveAmount); + } finally { + for (const [key, value] of Object.entries(originalEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearFeeModules(); + } + }); +} From 2f3aca00c224e596cb7fa8283f01a7fb497282b4 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:26:56 -0400 Subject: [PATCH 13/17] docs: record native zero-fee and currency policy checks [skip ci] Document zero-valued fee components and the focused 60-case run against product 452a512d with locked dependencies. The documented source and selection make the boundary distinct from historical full-suite claims. --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index fe367c9..04251d3 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,17 @@ and passed all 49 cases with no failures or skips, including decimal ties and HTTP preview/transfer agreement for GBP/USD, GBP/EUR, and JPY/EUR. This is the focused policy test file; it does not represent a complete-suite run. +Set `TRANSFER_FEE_PERCENT=0` or `TRANSFER_FEE_FLAT=0` to disable that +fee component; set both to zero to waive the fee. Missing or unparseable +values retain their defaults. + +The subsequent [fee-configuration run](https://github.com/woahwhattheheck/RemitFlow-Backend/actions/runs/37192051213) +checked out [`452a512d`](https://github.com/woahwhattheheck/RemitFlow-Backend/commit/452a512d8ae1d739336dcb8e4bd45e17300e0597). +With locked dependencies and Node.js 22.23.3, `node --test test/config.test.js test/currencyPolicy.test.js` +passed all 60 cases without failures or skips, including fee waivers and +the existing HTTP preview/transfer checks. This is the focused selection, +not a complete-suite result. + ### Transfers - `POST /api/transfers` — create a transfer. From b685aaeab819de52af02ebcfc0009703c141af13 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 05:30:44 -0400 Subject: [PATCH 14/17] fix: preserve conversion-rate precision in quote responses [skip ci] Keep the full computed dimensionless FX ratio in getPair and getQuote rather than rounding it like a currency amount. For NGN/USD, the old responses advertised rate 0 beside a positive 6.40 USD receive amount; they now advertise 0.00065. Fee and receive-amount rounding remain unchanged. Focused native Node 22.16.0 comparison on complete source 2e815f63 with these two service changes: test/exchangeRatePrecision.test.js went from 1 passed / 2 failed to 3 passed / 0 failed. The supported 72-pair matrix retains the conversion ratio after JSON serialization; separately compared full pair/quote records preserve every non-rate value. No dependency or test-runner changes. Reproduction with installed project dependencies: node --test test/exchangeRatePrecision.test.js. This local execution used an external inert dotenv.config preload because dotenv was unavailable; real config defaults, services, helpers and static rates executed. No .env loading, application HTTP, live provider, settlement database or full-suite result is claimed. The preload is not committed. Composed on current 2f3aca00, preserving four intervening configuration/currency-policy/documentation commits. Native comparison confirms neither service changed in that interval. Existing PR139 and issue130 remain the contribution path; no new claim or award assertion. --- src/services/quoteService.js | 4 +-- src/services/rateService.js | 4 +-- test/exchangeRatePrecision.test.js | 40 ++++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 test/exchangeRatePrecision.test.js diff --git a/src/services/quoteService.js b/src/services/quoteService.js index 6647ce7..093b0b1 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -2,7 +2,6 @@ const config = require('../config'); const rateService = require('./rateService'); -const money = require('../utils/money'); const currencyPolicy = require('../utils/currencyPolicy'); const ApiError = require('../utils/ApiError'); @@ -85,7 +84,8 @@ function getQuote(amount, from, to) { sendAmount: numericAmount, fee, amountAfterFee, - rate: money.round(rate), + // FX ratios are not currency amounts; retain the conversion precision. + rate, receiveAmount, }; } diff --git a/src/services/rateService.js b/src/services/rateService.js index 123267b..0286264 100644 --- a/src/services/rateService.js +++ b/src/services/rateService.js @@ -1,7 +1,6 @@ 'use strict'; const { RATES_TO_USD, SUPPORTED_CURRENCIES } = require('../config/rates'); -const money = require('../utils/money'); const currency = require('../utils/currency'); const currencyPolicy = require('../utils/currencyPolicy'); const ApiError = require('../utils/ApiError'); @@ -78,7 +77,8 @@ function getPair(from, to) { return { from: fromCode, to: toCode, - rate: money.round(getRate(fromCode, toCode)), + // Applying minor-unit rounding here can turn a valid FX rate into zero. + rate: getRate(fromCode, toCode), }; } diff --git a/test/exchangeRatePrecision.test.js b/test/exchangeRatePrecision.test.js new file mode 100644 index 0000000..30fc6b2 --- /dev/null +++ b/test/exchangeRatePrecision.test.js @@ -0,0 +1,40 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { SUPPORTED_CURRENCIES } = require('../src/config/rates'); +const rateService = require('../src/services/rateService'); +const quoteService = require('../src/services/quoteService'); + +test('small FX rates stay positive and retain the conversion ratio', () => { + const pair = rateService.getPair('NGN', 'USD'); + const quote = quoteService.getQuote(10000, 'NGN', 'USD'); + assert.equal(pair.rate, 0.00065); + assert.equal(quote.rate, pair.rate); + assert.ok(quote.receiveAmount > 0); +}); + +test('rate responses use the same ratio for every supported currency pair', () => { + for (const from of SUPPORTED_CURRENCIES) { + for (const to of SUPPORTED_CURRENCIES) { + if (from === to) continue; + const pair = rateService.getPair(from, to); + const quote = quoteService.getQuote(10000, from, to); + const expected = rateService.getRate(from, to); + assert.equal(pair.rate, expected, `${from}-${to} pair`); + assert.equal(quote.rate, expected, `${from}-${to} quote`); + assert.equal(JSON.parse(JSON.stringify(quote)).rate, expected); + assert.equal(quote.receiveAmount, rateService.convert(quote.amountAfterFee, from, to)); + } + } +}); + +test('rate precision does not relax quote validation or currency amount rounding', () => { + assert.throws(() => quoteService.getQuote(10.001, 'USD', 'EUR'), {statusCode: 400}); + assert.throws(() => quoteService.getQuote(100, 'USD', 'USD'), {statusCode: 400}); + assert.throws(() => rateService.getPair('USD', 'UNKNOWN'), {statusCode: 400}); + const yen = quoteService.getQuote(100, 'USD', 'JPY'); + assert.ok(Number.isInteger(yen.receiveAmount)); + assert.equal(yen.fee, 1.8); + assert.equal(yen.amountAfterFee, 98.2); +}); From 731294140d2a2a8c187c17ee49c2422e383659a5 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 07:54:58 -0400 Subject: [PATCH 15/17] docs: clarify source and destination rounding units --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 04251d3..a693afa 100644 --- a/README.md +++ b/README.md @@ -273,8 +273,10 @@ quote. A zero or negative payout after fees and destination rounding is rejected with `400` by preview and transfer creation, before settlement or transfer insertion. -Fee calculation and FX conversion retain their decimal inputs until one -rounding step to the destination minor units. Nonnegative funds round half up; +Fee calculation retains the original decimal factors until the combined fee +is rounded once to source-currency minor units. FX conversion then retains its +decimal factors until the payout is rounded once to destination-currency minor +units. Nonnegative funds round half up; negative intermediate values keep the existing ties-toward-positive-infinity behavior. For example, a `16.04 GBP` send has a `0.54 GBP` fee and a `19.69 USD` payout at the mock `1.27` rate (`15.50 × 1.27 = 19.685`). From 3581b9f397d38332c34662ff5c4a4c6f6a961499 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:05:42 -0400 Subject: [PATCH 16/17] perf(currency): avoid reparsing exact integer factors Convert safe integer Numbers directly to decimal ratios while preserving fractional, string and unsafe-integer parsing, exact rounding and readback validation. Include paired production-service measurements and a reproducible benchmark. --- benchmark/currencyPolicy-results.json | 113 ++++++++++++++++++++++++++ benchmark/currencyPolicy-results.md | 68 ++++++++++++++++ benchmark/currencyPolicy.js | 103 +++++++++++++++++++++++ src/utils/currencyPolicy.js | 5 ++ 4 files changed, 289 insertions(+) create mode 100644 benchmark/currencyPolicy-results.json create mode 100644 benchmark/currencyPolicy-results.md create mode 100644 benchmark/currencyPolicy.js diff --git a/benchmark/currencyPolicy-results.json b/benchmark/currencyPolicy-results.json new file mode 100644 index 0000000..32558e3 --- /dev/null +++ b/benchmark/currencyPolicy-results.json @@ -0,0 +1,113 @@ +{ + "node": "v24.19.0", + "cpu": "AMD EPYC 9V74 80-Core Processor", + "baseline_source_sha256": "040866fa299d0b3d62c5ccb4bc22b0682b6966c0a11a9e59fb17057a1e35a95b", + "candidate_source_sha256": "ff9e3fe1ed70d579a34d05b881fabc4f3428bd37a061711f7f94ab168bb7835a", + "fee": { + "percent": 1.5, + "flat": 0.3 + }, + "max_transfer_amount": 50000, + "compatibility": "Exact values, errors and all quote fields matched before timing.", + "measurements": [ + { + "workload": "minor-unit rounding", + "iterations": 74349, + "batches": { + "baseline": [ + 94.15192900000001, + 135.454968, + 92.80855700000001, + 131.19849899999997, + 99.84461099999999, + 97.00193600000011, + 119.84274099999993 + ], + "candidate": [ + 73.71571, + 90.14800199999996, + 111.95667800000001, + 105.47371699999997, + 64.20043299999998, + 65.52026999999998, + 101.883061 + ] + }, + "baseline_median_ms": 99.84461099999999, + "candidate_median_ms": 90.14800199999996, + "baseline_ops_per_second": 744647.0996817246, + "candidate_ops_per_second": 824743.7364169206, + "throughput_gain_percent": 10.75632158769313 + }, + { + "workload": "USD/EUR quote", + "iterations": 12566, + "batches": { + "baseline": [ + 91.27773200000001, + 135.25354400000015, + 101.85128299999997, + 108.40028299999994, + 130.87884699999995, + 89.10906299999988, + 90.75208500000008 + ], + "candidate": [ + 74.10715899999991, + 174.15487699999994, + 83.73231200000009, + 76.4790710000002, + 103.46399099999962, + 98.07308400000011, + 99.433673 + ] + }, + "baseline_median_ms": 101.85128299999997, + "candidate_median_ms": 98.07308400000011, + "baseline_ops_per_second": 123375.96179323537, + "candidate_ops_per_second": 128128.93698744077, + "throughput_gain_percent": 3.8524321311236065 + }, + { + "workload": "JPY/USD quote", + "iterations": 14462, + "batches": { + "baseline": [ + 121.19389499999988, + 113.82723499999975, + 121.73495999999977, + 120.18448599999965, + 400.0112469999999, + 115.10252899999978, + 115.16700599999967 + ], + "candidate": [ + 71.22120399999994, + 48.92068700000027, + 68.60766000000012, + 59.67435800000021, + 151.6297569999997, + 62.00409099999979, + 61.46536599999945 + ] + }, + "baseline_median_ms": 120.18448599999965, + "candidate_median_ms": 62.00409099999979, + "baseline_ops_per_second": 120331.67076156603, + "candidate_ops_per_second": 233242.67426160717, + "throughput_gain_percent": 93.83315529938187 + } + ], + "last_result": { + "from": "JPY", + "to": "USD", + "sendAmount": 40029, + "fee": 601, + "amountAfterFee": 39428, + "rate": 0.0067, + "receiveAmount": 264.17 + }, + "baseline_repository_blob": "654886208d3dc2bec0459fe3fb14526fa0f95218", + "baseline_repository_sha256": "37ccb6543eaa07407d20a0d29caa730bf606392facc74de8f0ead8157e0b2abf", + "baseline_materialization": "Pinned source plus one trailing newline; executable source is unchanged." +} diff --git a/benchmark/currencyPolicy-results.md b/benchmark/currencyPolicy-results.md new file mode 100644 index 0000000..986be6d --- /dev/null +++ b/benchmark/currencyPolicy-results.md @@ -0,0 +1,68 @@ +# Exact currency arithmetic throughput + +The decimal ratio helper now converts safe integer Numbers directly to BigInt +numerators with a denominator of one. Those values already have an exact +integer representation. This avoids decimal-string allocation, regular-expression +matching and power-of-ten construction for the identity factors, percentage +divisor, whole-currency amounts and integer readbacks used during rounding. + +All strings still use the existing decimal grammar. Unsafe integer Numbers, +fractional values, non-finite values, division by zero, tie handling, supported +currency checks and exact decimal readback retain the previous path. No cache, +currency table, fee, rate or API contract was changed. + +## Measurement + +Baseline product source: `b685aaeab819de52af02ebcfc0009703c141af13`. +The subsequent `731294140d2a2a8c187c17ee49c2422e383659a5` changes only the +README and is preserved by this continuation. + +Node.js v24.19.0 on an AMD EPYC 9V74 in a shared Linux cloud environment. +Each result uses seven paired batches, alternating execution order, after +warmup. Both versions receive the same inputs and iteration counts. Rates +below come from the median duration, with complete samples in +[currencyPolicy-results.json](currencyPolicy-results.json). + +| Actual production call | Baseline calls/s | Changed calls/s | Measured throughput change | +| --- | ---: | ---: | ---: | +| USD minor-unit rounding | 744,647 | 824,744 | +10.8% | +| USD/EUR `getQuote` | 123,376 | 128,129 | +3.9% | +| JPY/USD `getQuote` | 120,332 | 233,243 | +93.8% | + +The larger JPY gain reflects whole-unit amounts and fees reaching the integer +path repeatedly. The small USD/EUR difference should not be generalized from +one environment. Timing outliers remain in the raw batches. + +## Compatibility and scope + +Before timing, the command compared exact returned values and error names and +messages across rounding ties, explicit and absent factors, null defaults, +string decimals, an invalid hexadecimal factor, non-finite input, a zero +divisor, safe-integer limits, unsafe-integer factors and decimal readback +rejection. It also compared every returned field of both measured quote +workloads and all rounding inputs. Any mismatch exits nonzero. + +The command executes the real currency policy, quote service, rate service, +configuration and error helper from the two source trees. Fee configuration +is fixed to 1.5 percent plus 0.3 source units and a 50,000 source-unit transfer +ceiling for reproducibility. The original static demo FX table is unchanged. +The local runtime used upstream dotenv v16.6.1 source, matching the lockfile's +version. No dependency manifest or lockfile was changed. + +These results measure synchronous production service calls in process. They +exclude Express, HTTP, authorization, provider latency and live Stellar +settlement. Existing unrelated checks were not repeated; this is not a new +full-suite or hosted-CI result. + +## Reproduce + +Provide a source checkout of the pinned baseline with its normal dependencies: + +```sh +node benchmark/currencyPolicy.js /path/to/baseline-checkout +``` + +The command runs the current checkout as the candidate, validates parity, +then prints JSON with source hashes and all measured batches. The captured +baseline file had one additional trailing newline; its measured hash and the +exact repository hash are recorded separately in the results. diff --git a/benchmark/currencyPolicy.js b/benchmark/currencyPolicy.js new file mode 100644 index 0000000..2061e7d --- /dev/null +++ b/benchmark/currencyPolicy.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +'use strict'; + +// Compare real service modules with a separate checkout of the previous source. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const os = require('node:os'); +const { performance } = require('node:perf_hooks'); + +if (!process.argv[2]) { + console.error('Usage: node benchmark/currencyPolicy.js /path/to/baseline-checkout'); + process.exit(1); +} +process.env.TRANSFER_FEE_PERCENT = '1.5'; +process.env.TRANSFER_FEE_FLAT = '0.3'; +process.env.MAX_TRANSFER_AMOUNT = '50000'; +const previous = path.resolve(process.argv[2]); +const current = path.resolve(__dirname, '..'); +const load = root => ({ + policy: require(path.join(root, 'src/utils/currencyPolicy.js')), + quote: require(path.join(root, 'src/services/quoteService.js')), +}); +const baseline = load(previous); +const candidate = load(current); +const args = [ + [123.455, 'USD'], + [-1.005, 'USD'], + [3, 'JPY', { divisor: 2 }], + [-3, 'JPY', { divisor: 2 }], + [123.45, 'USD', { multiplier: null, divisor: null, addend: null }], + ['12.345', 'USD', { multiplier: '1', divisor: '1', addend: '0' }], + [1, 'USD', { divisor: 0 }], + [1, 'USD', { multiplier: '0x1' }], + [1, 'USD', { multiplier: Infinity }], + [Number.MAX_SAFE_INTEGER, 'JPY'], + [1, 'USD', { multiplier: Number.MAX_SAFE_INTEGER, divisor: Number.MAX_SAFE_INTEGER }], + [1e30, 'JPY', { divisor: 1e30 }], + ['90071992547409.91', 'USD'], +]; +function capture(run) { + try { return { value: run() }; } + catch (error) { return { error: error.name, message: error.message }; } +} +for (const input of args) { + assert.deepStrictEqual( + capture(() => candidate.policy.roundToCurrency(...input)), + capture(() => baseline.policy.roundToCurrency(...input)) + ); +} + +const inputs = Array.from({ length: 32 }, (_, index) => 1000 + index + 0.05); +const tasks = [ + { name: 'minor-unit rounding', run: (modules, index) => modules.policy.roundToCurrency(inputs[index % 32], 'USD') }, + { name: 'USD/EUR quote', run: (modules, index) => modules.quote.getQuote(inputs[index % 32], 'USD', 'EUR') }, + { name: 'JPY/USD quote', run: (modules, index) => modules.quote.getQuote(40000 + index % 32, 'JPY', 'USD') }, +]; +for (const task of tasks) { + for (let index = 0; index < 32; index += 1) { + assert.deepStrictEqual(task.run(candidate, index), task.run(baseline, index)); + } +} +let observed; +function timed(task, modules, iterations) { + const start = performance.now(); + for (let index = 0; index < iterations; index += 1) observed = task.run(modules, index); + return performance.now() - start; +} +function median(values) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.floor(sorted.length / 2)]; +} +const measurements = tasks.map(task => { + timed(task, baseline, 2000); + timed(task, candidate, 2000); + const calibration = timed(task, baseline, 2000); + const iterations = Math.max(2000, Math.min(100000, Math.ceil(2000 * 120 / calibration))); + const times = { baseline: [], candidate: [] }; + for (let batch = 0; batch < 7; batch += 1) { + const order = batch % 2 === 0 ? ['baseline', 'candidate'] : ['candidate', 'baseline']; + for (const name of order) times[name].push(timed(task, name === 'baseline' ? baseline : candidate, iterations)); + } + const before = median(times.baseline); + const after = median(times.candidate); + return { + workload: task.name, iterations, batches: times, + baseline_median_ms: before, candidate_median_ms: after, + baseline_ops_per_second: iterations * 1000 / before, + candidate_ops_per_second: iterations * 1000 / after, + throughput_gain_percent: (before / after - 1) * 100, + }; +}); +const sha256 = filename => crypto.createHash('sha256').update(fs.readFileSync(filename)).digest('hex'); +console.log(JSON.stringify({ + node: process.version, cpu: os.cpus()[0].model, + baseline_source_sha256: sha256(path.join(previous, 'src/utils/currencyPolicy.js')), + candidate_source_sha256: sha256(path.join(current, 'src/utils/currencyPolicy.js')), + fee: { percent: 1.5, flat: 0.3 }, max_transfer_amount: 50000, + compatibility: 'Exact values, errors and all quote fields matched before timing.', + measurements, + last_result: observed, +}, null, 2)); diff --git a/src/utils/currencyPolicy.js b/src/utils/currencyPolicy.js index 6548862..767ddcb 100644 --- a/src/utils/currencyPolicy.js +++ b/src/utils/currencyPolicy.js @@ -114,6 +114,11 @@ function decimalRatio(value) { throw new RangeError('amount is outside the supported numeric range'); } if (numeric === 0) return [0n, 1n]; + // Safe integer Numbers already are exact decimal integers. In particular, + // the identity factors used by rounding need no string or regexp parsing. + if (typeof value === 'number' && Number.isSafeInteger(value)) { + return [BigInt(value), 1n]; + } const text = typeof value === 'string' ? value.trim() : String(numeric); const match = /^([+-]?)(\d+)(?:\.(\d+))?(?:e([+-]?\d+))?$/i.exec(text); if (!match) { From d7208480473aa35db99dcf4ffc392d092048ffaa Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 4 Oct 2026 08:28:01 -0400 Subject: [PATCH 17/17] fix(quote): return validation errors for destination overflow A source amount accepted by the configured transfer ceiling can overflow the destination currency's minor-unit range during conversion. With MAX_TRANSFER_AMOUNT=1000000000000, a 1000000000000 USD to NGN quote previously raised a plain RangeError and reached the error handler as an internal error. Translate that conversion-range failure into the existing ApiError 400 contract while preserving other exceptions and the exact rounding implementation. Add one focused regression through the production quote, transfer, error-handler, store, audit and idempotency modules. Rejected overflow makes no settlement call and inserts no records. A corrected 50000 USD request reuses the same key, yields 75768769.23 NGN, and replay retains the same transfer with only one settlement call. Validation: node --test test/quoteOverflow.test.js. Original quote blob 093b0b12 fails the same regression (exit 1); changed quote blob 1fc53199 passes (exit 0, one case). Node v24.19.0, dotenv 16.6.1 and existing runtime uuid 3.4.0. The shipped settlement adapter is local/mock, and the error handler uses a response sink. Scope is service and error-envelope behavior; no Express HTTP, complete-suite or live-settlement result is asserted. Continue the original issue #130 contribution and preserve parent 3581b9f3 and all earlier precision, fee, rate and throughput work. --- src/services/quoteService.js | 13 ++++-- test/quoteOverflow.test.js | 88 ++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 test/quoteOverflow.test.js diff --git a/src/services/quoteService.js b/src/services/quoteService.js index 093b0b1..1fc5319 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -67,11 +67,14 @@ function getQuote(amount, from, to) { const rate = rateService.getRate(fromCode, toCode); // Receive side rounds to the *destination* currency's minor units so a // JPY payout never carries fractional yen that settlement cannot pay. - const receiveAmount = rateService.convert( - amountAfterFee, - fromCode, - toCode - ); + let receiveAmount; + try { + receiveAmount = rateService.convert(amountAfterFee, fromCode, toCode); + } catch (err) { + // Valid source units can still overflow the destination's minor units. + if (!(err instanceof RangeError)) throw err; + throw ApiError.badRequest('receive amount is outside the supported numeric range'); + } if (receiveAmount <= 0) { throw ApiError.badRequest( 'Amount must produce a positive receive amount after fees and currency rounding' diff --git a/test/quoteOverflow.test.js b/test/quoteOverflow.test.js new file mode 100644 index 0000000..1deaeef --- /dev/null +++ b/test/quoteOverflow.test.js @@ -0,0 +1,88 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +process.env.NODE_ENV = 'test'; +process.env.MAX_TRANSFER_AMOUNT = '1000000000000'; +process.env.TRANSFER_FEE_PERCENT = '1.5'; +process.env.TRANSFER_FEE_FLAT = '0.3'; +process.env.ERROR_TRACKING_ENABLED = 'false'; + +const currencyPolicy = require('../src/utils/currencyPolicy'); +const quoteService = require('../src/services/quoteService'); +const transferService = require('../src/services/transferService'); +const stellarService = require('../src/services/stellarService'); +const idempotencyService = require('../src/services/idempotencyService'); +const auditService = require('../src/services/auditService'); +const errorHandler = require('../src/middleware/errorHandler'); +const ApiError = require('../src/utils/ApiError'); +const { store, reset } = require('../src/store'); + +test('destination overflow returns 400 without settlement or a burned idempotency key', (t) => { + reset(); + t.after(reset); + // The production settlement adapter is local/mock; retain its real behavior. + const settlement = t.mock.method(stellarService, 'submitPayment'); + const data = { + senderName: 'Sender', + recipientName: 'Recipient', + amount: 1000000000000, + from: 'USD', + to: 'NGN', + }; + const context = { + actor: 'overflow-test', + key: 'retry-after-overflow', + fingerprint: idempotencyService.fingerprint(data), + }; + assert.equal(currencyPolicy.canonicalizeAmount(data.amount, data.from, { + enforceMax: true, + }).ok, true, 'the send amount is valid; only the converted destination overflows'); + + for (const operation of [ + () => quoteService.getQuote(data.amount, data.from, data.to), + () => transferService.createTransfer(data, 'overflow-request', context), + ]) { + assert.throws(operation, (err) => { + assert.ok(err instanceof ApiError); + assert.equal(err.statusCode, 400); + const response = { + status(code) { this.statusCode = code; return this; }, + json(body) { this.body = body; return this; }, + }; + errorHandler(err, { id: 'overflow-request' }, response, () => {}); + assert.equal(response.statusCode, 400); + assert.equal(response.body.error.status, 400); + assert.equal(response.body.error.message, err.message); + assert.equal(response.body.error.requestId, 'overflow-request'); + return true; + }); + } + + assert.equal(settlement.mock.callCount(), 0); + assert.equal(store.transfers.size, 0); + assert.equal(store.transferIndex.size, 0); + assert.equal(store.idempotency.size, 0); + assert.equal(auditService.countEntries(), 0); + + const corrected = { ...data, amount: 50000 }; + const retryContext = { + ...context, + fingerprint: idempotencyService.fingerprint(corrected), + }; + const transfer = transferService.createTransfer(corrected, 'retry-request', retryContext); + assert.equal(transfer.receiveAmount, 75768769.23); + assert.equal(transfer.sendAmount, 50000); + assert.equal(transfer.status, 'pending'); + assert.equal(settlement.mock.callCount(), 1); + assert.equal(store.transfers.size, 1); + assert.equal(store.transferIndex.size, 1); + assert.equal(store.idempotency.size, 1); + assert.equal(auditService.countEntries(), 1); + assert.equal( + transferService.createTransfer(corrected, 'replay-request', retryContext), + transfer, + ); + assert.equal(settlement.mock.callCount(), 1, 'a replay does not settle a second time'); +});