Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
23c33e6
feat(backend): centralize amount and currency validation
woahwhattheheck Sep 24, 2026
cb025ce
fix(quote): enforce transfer amount ceiling in preview
woahwhattheheck Sep 26, 2026
70b23f7
fix(quote): align validation ceiling with transfer
woahwhattheheck Sep 26, 2026
3d073fc
test(quote): cover over-ceiling preview and transfer agreement
woahwhattheheck Sep 26, 2026
1c52e95
fix(quote): round percentage and flat fees together
woahwhattheheck Oct 3, 2026
e79ead3
fix: reject transfers with nonpositive recipient amounts
woahwhattheheck Oct 3, 2026
46c0e75
fix(validation): reject structured amounts without object coercion
woahwhattheheck Oct 3, 2026
cf24ce9
docs: describe the shared quote and transfer currency policy
woahwhattheheck Oct 4, 2026
8f4bd08
fix: preserve decimal factors through currency rounding
woahwhattheheck Oct 4, 2026
2e815f6
docs: record reproducible currency policy checks
woahwhattheheck Oct 4, 2026
d31a56e
fix(currency): reject lossy numeric minor-unit output [skip ci]
woahwhattheheck Oct 4, 2026
452a512
fix(config): honor zero transfer fee components
woahwhattheheck Oct 4, 2026
231d3e7
fix(currency): preserve exact minor units in numeric API output [skip…
woahwhattheheck Oct 4, 2026
2f3aca0
docs: record native zero-fee and currency policy checks [skip ci]
woahwhattheheck Oct 4, 2026
b685aae
fix: preserve conversion-rate precision in quote responses [skip ci]
woahwhattheheck Oct 4, 2026
7312941
docs: clarify source and destination rounding units
woahwhattheheck Oct 4, 2026
3581b9f
perf(currency): avoid reparsing exact integer factors
woahwhattheheck Oct 4, 2026
d720848
fix(quote): return validation errors for destination overflow
woahwhattheheck Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
91 changes: 85 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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=<id>&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`.
113 changes: 113 additions & 0 deletions benchmark/currencyPolicy-results.json
Original file line number Diff line number Diff line change
@@ -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."
}
68 changes: 68 additions & 0 deletions benchmark/currencyPolicy-results.md
Original file line number Diff line number Diff line change
@@ -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.
Loading