diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e4e0a..a943530 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: @@ -55,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/README.md b/README.md index 4791414..a693afa 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,75 @@ 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. + +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`). + +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. + +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 @@ -354,3 +418,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/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/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/src/services/quoteService.js b/src/services/quoteService.js index 3cba17a..1fc5319 100644 --- a/src/services/quoteService.js +++ b/src/services/quoteService.js @@ -2,54 +2,84 @@ 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) { + return currencyPolicy.roundToCurrency(amount, fromCode, { + multiplier: config.fee.percent, + divisor: 100, + addend: config.fee.flat, + }); } /** * 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: true, + }); + 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: ${currencyPolicy.describeCurrency(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, + fromCode, + { addend: -fee } + ); 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. + 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' + ); + } return { from: fromCode, @@ -57,7 +87,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 95c7112..0286264 100644 --- a/src/services/rateService.js +++ b/src/services/rateService.js @@ -1,8 +1,8 @@ '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'); /** @@ -39,10 +39,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]; @@ -56,8 +56,13 @@ function getRate(from, to) { * @returns {number} */ function convert(amount, from, to) { - const rate = getRate(from, to); - return money.round(amount * rate); + getRate(from, to); // Preserve unsupported-currency validation. + const fromCode = currency.normalize(from); + const toCode = currency.normalize(to); + return currencyPolicy.roundToCurrency(amount, toCode, { + multiplier: RATES_TO_USD[fromCode], + divisor: RATES_TO_USD[toCode], + }); } /** @@ -72,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/src/utils/currencyPolicy.js b/src/utils/currencyPolicy.js new file mode 100644 index 0000000..767ddcb --- /dev/null +++ b/src/utils/currencyPolicy.js @@ -0,0 +1,355 @@ +'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); +} + +/** 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} + */ +function getMeta(code) { + const normalized = currency.normalize(code); + const meta = CURRENCY_META[normalized]; + if (!meta) { + const err = new Error(`Unsupported currency: ${describeCurrency(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; +} + +/** 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]; + // 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) { + 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 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, options = {}) { + const { minorUnits } = getMeta(code); + const factor = 10 ** minorUnits; + 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'); + } + 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. */ +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 }`. + * + * @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: ${describeCurrency(code)}`); + return { ok: false, errors }; + } + + if (amount === undefined || amount === null || amount === '') { + errors.push('amount is required'); + return { ok: false, errors }; + } + + const n = parseAmountNumber(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 }; + } + + 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}`); + 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: ${describeCurrency(from)}`); + } + + if (!to) { + errors.push('to currency is required'); + } else if (!isSupported(to)) { + errors.push(`Unsupported target currency: ${describeCurrency(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(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. + errors.push(`${amountLabel} must be a positive number`); + } + + return errors; +} + +module.exports = { + CURRENCY_META, + isSupported, + describeCurrency, + getMeta, + effectiveMax, + maxSafeMagnitude, + roundToCurrency, + canonicalizeAmount, + hasValidPrecisionFor, + validateTransferPair, +}; diff --git a/src/utils/money.js b/src/utils/money.js index 04d3e53..d1ed1ff 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,45 @@ 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) { +function percentage(amount, percent, currencyCode) { + if (currencyCode) { + const currencyPolicy = require('./currencyPolicy'); + return currencyPolicy.roundToCurrency(amount, currencyCode, { + multiplier: percent, + divisor: 100, + }); + } return round(Number(amount) * (Number(percent) / 100)); } /** * 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..bd7ceff 100644 --- a/src/validators/quoteValidator.js +++ b/src/validators/quoteValidator.js @@ -1,37 +1,21 @@ '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; + // A successful preview must also pass the transfer amount ceiling. + return currencyPolicy.validateTransferPair(amount, from, to, { + enforceMax: true, + }); } 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/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(); + } + }); +} diff --git a/test/currencyPolicy.test.js b/test/currencyPolicy.test.js new file mode 100644 index 0000000..f9a18f2 --- /dev/null +++ b/test/currencyPolicy.test.js @@ -0,0 +1,574 @@ +'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 rateService = require('../src/services/rateService'); +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 { store, reset } = require('../src/store'); +const money = require('../src/utils/money'); + +// ── 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('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); + 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); +}); + +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', () => { + 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); +}); + +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'), + (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 }; +} + +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'); + 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); + assert.ok( + body.error.details.errors.some((e) => /Unsupported target currency/i.test(e)) + ); +}); + +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); + 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); +}); + +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); +}); + +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); + }); +} + + +// 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); +}); 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); +}); 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'); +});