From 820f4c64f928692422f4823e4749ad6de6fbc3d3 Mon Sep 17 00:00:00 2001 From: ayom04 Date: Sun, 23 Aug 2026 16:36:04 +0100 Subject: [PATCH 1/2] Add comprehensive unit tests for transfer and quote contracts, status badges, and money utilities - Implement tests for StatusBadge component to ensure correct rendering of transfer statuses and aliases. - Create tests for contract schema validation, covering various scenarios including valid and invalid payloads. - Add tests for money utility functions to validate parsing, formatting, and arithmetic operations on monetary values. - Introduce tests for quote contract parsing and fee calculation, ensuring accurate handling of quotes and associated rates. - Develop tests for receipt rendering components to verify correct display of transfer and quote information. - Ensure robust handling of breaking changes in transfer and quote contracts with actionable error messages. --- src/components/QuoteCard.jsx | 9 +- src/components/StatusBadge.css | 50 +++ src/components/StatusBadge.jsx | 48 +- src/components/StatusBadge.stories.jsx | 65 ++- src/components/TransferRow.jsx | 11 +- src/hooks/useTransfers.js | 14 +- src/pages/SendMoney.jsx | 27 +- src/pages/Transfers.jsx | 41 +- src/print.css | 26 ++ src/services/api.js | 90 +++- src/services/contracts/README.md | 49 ++ src/services/contracts/quote.js | 51 +++ src/services/contracts/schema.js | 311 +++++++++++++ src/services/contracts/transfer.js | 155 +++++++ src/services/fx.js | 58 ++- src/services/quote.js | 106 ++++- src/utils/format.js | 52 ++- src/utils/money.js | 417 ++++++++++++++++++ src/utils/validate.js | 22 +- test/fixtures/index.js | 103 +++++ .../v1/breaking/quote.formatted-rate.json | 12 + .../v1/breaking/quote.missing-expiry.json | 11 + .../v1/breaking/transfer.epoch-timestamp.json | 10 + .../transfer.money-object-amount.json | 10 + .../breaking/transfer.non-iso-currency.json | 10 + .../v1/breaking/transfer.null-amount.json | 10 + .../v1/breaking/transfer.renamed-amount.json | 10 + .../v1/breaking/transfer.unknown-status.json | 10 + test/fixtures/v1/quote.cross-rate.json | 12 + test/fixtures/v1/quote.json | 12 + test/fixtures/v1/transfer.authorizing.json | 12 + .../v1/transfer.completed-legacy.json | 10 + test/fixtures/v1/transfer.expired.json | 14 + test/fixtures/v1/transfer.failed.json | 13 + .../v1/transfer.forward-compatible.json | 15 + test/fixtures/v1/transfer.pending.json | 12 + test/fixtures/v1/transfer.quoted.json | 13 + test/fixtures/v1/transfer.settled.json | 12 + test/fixtures/v1/transfer.validating.json | 12 + .../integration/send-money-precision.test.jsx | 247 +++++++++++ test/integration/transfer-states.test.jsx | 174 ++++++++ test/unit/StatusBadge.test.jsx | 48 ++ test/unit/contract-schema.test.js | 210 +++++++++ test/unit/money.test.js | 204 +++++++++ test/unit/quote-contract.test.js | 222 ++++++++++ test/unit/receipt-rendering.test.jsx | 103 +++++ test/unit/transfer-contract.test.js | 267 +++++++++++ 47 files changed, 3276 insertions(+), 124 deletions(-) create mode 100644 src/services/contracts/README.md create mode 100644 src/services/contracts/quote.js create mode 100644 src/services/contracts/schema.js create mode 100644 src/services/contracts/transfer.js create mode 100644 src/utils/money.js create mode 100644 test/fixtures/index.js create mode 100644 test/fixtures/v1/breaking/quote.formatted-rate.json create mode 100644 test/fixtures/v1/breaking/quote.missing-expiry.json create mode 100644 test/fixtures/v1/breaking/transfer.epoch-timestamp.json create mode 100644 test/fixtures/v1/breaking/transfer.money-object-amount.json create mode 100644 test/fixtures/v1/breaking/transfer.non-iso-currency.json create mode 100644 test/fixtures/v1/breaking/transfer.null-amount.json create mode 100644 test/fixtures/v1/breaking/transfer.renamed-amount.json create mode 100644 test/fixtures/v1/breaking/transfer.unknown-status.json create mode 100644 test/fixtures/v1/quote.cross-rate.json create mode 100644 test/fixtures/v1/quote.json create mode 100644 test/fixtures/v1/transfer.authorizing.json create mode 100644 test/fixtures/v1/transfer.completed-legacy.json create mode 100644 test/fixtures/v1/transfer.expired.json create mode 100644 test/fixtures/v1/transfer.failed.json create mode 100644 test/fixtures/v1/transfer.forward-compatible.json create mode 100644 test/fixtures/v1/transfer.pending.json create mode 100644 test/fixtures/v1/transfer.quoted.json create mode 100644 test/fixtures/v1/transfer.settled.json create mode 100644 test/fixtures/v1/transfer.validating.json create mode 100644 test/integration/send-money-precision.test.jsx create mode 100644 test/integration/transfer-states.test.jsx create mode 100644 test/unit/StatusBadge.test.jsx create mode 100644 test/unit/contract-schema.test.js create mode 100644 test/unit/money.test.js create mode 100644 test/unit/quote-contract.test.js create mode 100644 test/unit/receipt-rendering.test.jsx create mode 100644 test/unit/transfer-contract.test.js diff --git a/src/components/QuoteCard.jsx b/src/components/QuoteCard.jsx index a16d5e5..c978915 100644 --- a/src/components/QuoteCard.jsx +++ b/src/components/QuoteCard.jsx @@ -1,4 +1,5 @@ -import { formatAmount, formatRate, formatPercent } from '../utils/format.js'; +import { formatRate, formatPercent } from '../utils/format.js'; +import { formatMoney } from '../utils/money.js'; import { FEE_PERCENT } from '../constants/fees.js'; import { DEFAULT_LOCALE } from '../constants/locales.js'; import './QuoteCard.css'; @@ -20,12 +21,12 @@ export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {
You send - {formatAmount(sendAmount, from, locale)} + {formatMoney(sendAmount, from, locale)}
RemitFlow fee ({formatPercent(FEE_PERCENT, 1)} + flat) - - {formatAmount(fee, from, locale)} + - {formatMoney(fee, from, locale)}
@@ -37,7 +38,7 @@ export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {
Recipient gets - {formatAmount(receiveAmount, to, locale)} + {formatMoney(receiveAmount, to, locale)}

diff --git a/src/components/StatusBadge.css b/src/components/StatusBadge.css index 43baa1c..f3ef73d 100644 --- a/src/components/StatusBadge.css +++ b/src/components/StatusBadge.css @@ -8,6 +8,23 @@ letter-spacing: 0.03em; } +/* Pre-submission states share a neutral/blue family; in-flight states are + amber; terminal states are green (settled), red (failed) or grey (expired). */ +.status-quoted { + background: rgba(99, 102, 241, 0.15); + color: #a5b4fc; +} + +.status-validating { + background: rgba(56, 189, 248, 0.15); + color: #7dd3fc; +} + +.status-authorizing { + background: rgba(168, 85, 247, 0.15); + color: #d8b4fe; +} + .status-pending { background: rgba(234, 179, 8, 0.15); color: #fde047; @@ -23,7 +40,35 @@ color: #f87171; } +.status-expired { + background: rgba(148, 163, 184, 0.15); + color: #cbd5e1; +} + +/* A status the contract does not recognise. Rendered deliberately plainly so + it reads as "unexpected" rather than as one of the known states. */ +.status-unknown { + background: rgba(148, 163, 184, 0.12); + color: #94a3b8; + border: 1px dashed #64748b; +} + @media (prefers-contrast: more) { + .status-quoted { + background: rgba(99, 102, 241, 0.35); + border: 1px solid #a5b4fc; + } + + .status-validating { + background: rgba(56, 189, 248, 0.35); + border: 1px solid #7dd3fc; + } + + .status-authorizing { + background: rgba(168, 85, 247, 0.35); + border: 1px solid #d8b4fe; + } + .status-pending { background: rgba(234, 179, 8, 0.35); border: 1px solid #fde047; @@ -38,4 +83,9 @@ background: rgba(239, 68, 68, 0.35); border: 1px solid #f87171; } + + .status-expired { + background: rgba(148, 163, 184, 0.35); + border: 1px solid #cbd5e1; + } } diff --git a/src/components/StatusBadge.jsx b/src/components/StatusBadge.jsx index f745945..3e75a39 100644 --- a/src/components/StatusBadge.jsx +++ b/src/components/StatusBadge.jsx @@ -1,18 +1,56 @@ +import { normalizeStatus } from '../services/contracts/transfer.js'; import './StatusBadge.css'; -// Human-readable labels for each transfer status. -const LABELS = { +/** + * Human-readable labels for every state in the transfer lifecycle. Exported so + * the transfers filter and the badge cannot drift apart; the contract test + * asserts this covers TRANSFER_STATUSES exactly. + */ +export const TRANSFER_STATUS_LABELS = { + quoted: 'Quoted', + validating: 'Validating', + authorizing: 'Authorizing', pending: 'Pending', completed: 'Completed', failed: 'Failed', + expired: 'Expired', +}; + +// Short explanations surfaced as a tooltip/accessible description, so a +// colour-coded pill is not the only way to know what a state means. +const DESCRIPTIONS = { + quoted: 'Quote prepared, not yet submitted', + validating: 'Checking recipient and amount', + authorizing: 'Waiting for wallet authorization', + pending: 'Sent, waiting to settle', + completed: 'Funds delivered', + failed: 'Transfer did not go through', + expired: 'Quote expired before authorization', }; /** * Colored badge showing a transfer status. + * + * Legacy and provider spellings (`settled`, `submitted`, ...) are normalised + * through the transfer contract. A genuinely unknown status renders as a + * neutral badge with the raw value rather than an unstyled, unlabelled pill. + * * @param {object} props - * @param {'pending'|'completed'|'failed'} props.status + * @param {string} props.status - a status from TRANSFER_STATUSES or a known alias */ export default function StatusBadge({ status }) { - const label = LABELS[status] || status; - return {label}; + const canonical = normalizeStatus(status); + const label = canonical + ? TRANSFER_STATUS_LABELS[canonical] + : status || 'Unknown'; + const description = canonical ? DESCRIPTIONS[canonical] : undefined; + + return ( + + {label} + + ); } diff --git a/src/components/StatusBadge.stories.jsx b/src/components/StatusBadge.stories.jsx index e579ff8..465212c 100644 --- a/src/components/StatusBadge.stories.jsx +++ b/src/components/StatusBadge.stories.jsx @@ -1,4 +1,8 @@ import StatusBadge from './StatusBadge.jsx'; +import { + TRANSFER_STATUSES, + TRANSFER_STATUS_ALIASES, +} from '../services/contracts/transfer.js'; export default { title: 'Components/StatusBadge', @@ -7,35 +11,54 @@ export default { argTypes: { status: { control: 'select', - options: ['pending', 'completed', 'failed'], + // Driven by the contract so the control cannot drift from the data. + options: [...TRANSFER_STATUSES, ...Object.keys(TRANSFER_STATUS_ALIASES)], }, }, }; -export const Pending = { - args: { - status: 'pending', - }, -}; - -export const Completed = { - args: { - status: 'completed', - }, -}; +export const Quoted = { args: { status: 'quoted' } }; +export const Validating = { args: { status: 'validating' } }; +export const Authorizing = { args: { status: 'authorizing' } }; +export const Pending = { args: { status: 'pending' } }; +export const Completed = { args: { status: 'completed' } }; +export const Failed = { args: { status: 'failed' } }; +export const Expired = { args: { status: 'expired' } }; -export const Failed = { - args: { - status: 'failed', - }, +export const AllStatuses = { + render: () => ( +

+ {TRANSFER_STATUSES.map((status) => ( + + ))} +
+ ), }; -export const AllStatuses = { +/** Provider spellings the adapter normalises on the way in. */ +export const LegacySpellings = { render: () => ( -
- - - +
+ {Object.keys(TRANSFER_STATUS_ALIASES).map((alias) => ( + + ))}
), }; + +/** A status the contract does not know: visibly unexpected, never blank. */ +export const UnknownStatus = { args: { status: 'in_flight' } }; diff --git a/src/components/TransferRow.jsx b/src/components/TransferRow.jsx index a677173..7cf0597 100644 --- a/src/components/TransferRow.jsx +++ b/src/components/TransferRow.jsx @@ -1,12 +1,15 @@ import StatusBadge from './StatusBadge.jsx'; -import { formatAmount, formatDate, shortenAddress } from '../utils/format.js'; +import { formatDate, shortenAddress } from '../utils/format.js'; +import { formatMoney } from '../utils/money.js'; import { DEFAULT_LOCALE } from '../constants/locales.js'; import './TransferRow.css'; /** * A single row in the transfers list. * @param {object} props - * @param {object} props.transfer - the transfer record + * @param {object} props.transfer - a contract-normalised transfer record; + * amounts are decimal strings and are rendered with formatMoney so an + * unparseable value shows a placeholder rather than a fabricated 0.00 * @param {string} [props.locale] - locale used for currency/date formatting * @param {boolean} [props.selected] - whether this row is selected * @param {Function} [props.onToggleSelect] - called when the checkbox is toggled @@ -43,12 +46,12 @@ export default function TransferRow({
Sent - {formatAmount(sendAmount, from, locale)} + {formatMoney(sendAmount, from, locale)}
Received - {formatAmount(receiveAmount, to, locale)} + {formatMoney(receiveAmount, to, locale)}
diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index fa9d448..45c92cb 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; +import { ContractViolationError } from '../services/contracts/schema.js'; /** * Hook for loading and creating transfers. @@ -17,8 +18,17 @@ export function useTransfers() { try { const data = await listTransfers(); setTransfers(data); - } catch { - setError('Could not load transfers. Please try again.'); + } catch (err) { + if (err instanceof ContractViolationError) { + // A schema change, not a flaky request. Retrying will not help, and + // showing an empty list would imply the transfers no longer exist. + console.error(err.message); + setError( + `Your transfers could not be displayed: the data did not match the expected format (${err.contract}). Nothing has been lost — please try again shortly.`, + ); + } else { + setError('Could not load transfers. Please try again.'); + } } finally { setLoading(false); } diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index 344b26c..8c6a037 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -6,6 +6,7 @@ import QuoteCard from '../components/QuoteCard.jsx'; import Button from '../components/Button.jsx'; import ErrorMessage from '../components/ErrorMessage.jsx'; import { buildQuote } from '../services/quote.js'; +import { ContractViolationError } from '../services/contracts/schema.js'; import { formatCurrencyInput } from '../utils/format.js'; import { isPositiveAmount, @@ -100,18 +101,40 @@ export default function SendMoney() { // Build from the live amount so a pending debounce can't submit a stale quote. const finalQuote = buildQuote(amount, from, to); - if (!finalQuote) return; + if (!finalQuote) { + // Previously this returned silently, leaving the user on an enabled + // button with no explanation of why nothing happened. + setSubmitError( + 'We could not price this transfer. Check the amount and the selected currencies.', + ); + return; + } + // Record the fee, rate and expiry alongside the amounts so the receipt + // can reproduce exactly what was quoted rather than re-deriving it from + // a rate that may since have moved. await addTransfer({ recipient, from, to, sendAmount: finalQuote.sendAmount, receiveAmount: finalQuote.receiveAmount, + fee: finalQuote.fee, + rate: finalQuote.rate, + expiresAt: finalQuote.expiresAt, }); navigate('/transfers'); } catch (err) { - setSubmitError('Could not submit the transfer. Please try again.'); + if (err instanceof ContractViolationError) { + // The full field-by-field diff goes to the console; the user gets a + // message that distinguishes "we rejected this" from "try again". + console.error(err.message); + setSubmitError( + 'This transfer was rejected before it was sent because the details did not match the expected format. Nothing was submitted.', + ); + } else { + setSubmitError('Could not submit the transfer. Please try again.'); + } } finally { submissionLock.current = false; setSubmitting(false); diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index 2967fe8..91e1f90 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -1,8 +1,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import Chart from '../components/Chart.jsx'; -import { formatAmount } from '../utils/format.js'; +import { formatMoney, parseDecimal } from '../utils/money.js'; +import { + TRANSFER_STATUSES, + normalizeStatus, +} from '../services/contracts/transfer.js'; import TransferRow from '../components/TransferRow.jsx'; +import { TRANSFER_STATUS_LABELS } from '../components/StatusBadge.jsx'; import Skeleton from '../components/Skeleton.jsx'; import ErrorMessage from '../components/ErrorMessage.jsx'; import EmptyState from '../components/EmptyState.jsx'; @@ -15,11 +20,14 @@ import { useApp } from '../context/AppContext.jsx'; import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; import './Transfers.css'; +// Derived from the contract so a new lifecycle state cannot be filterable in +// the data but missing from the dropdown. const STATUS_OPTIONS = [ { value: '', label: 'All statuses' }, - { value: 'pending', label: 'Pending' }, - { value: 'completed', label: 'Completed' }, - { value: 'failed', label: 'Failed' }, + ...TRANSFER_STATUSES.map((value) => ({ + value, + label: TRANSFER_STATUS_LABELS[value], + })), ]; const PAGE_SIZE = 5; @@ -51,15 +59,19 @@ export default function Transfers() { setSelectAllAcross(false); }, [search, status, range]); + // Normalise the query-string status so a legacy or provider spelling in a + // shared/bookmarked URL (?status=settled) still selects the right rows. + const canonicalStatus = normalizeStatus(status); + const filteredTransfers = useMemo(() => { return transfers.filter((t) => { - if (status && t.status !== status) return false; + if (status && normalizeStatus(t.status) !== canonicalStatus) return false; if (search && !t.recipient.toLowerCase().includes(search.toLowerCase())) return false; if (!isWithinDateRange(t.createdAt, range)) return false; return true; }); - }, [transfers, search, status, range]); + }, [transfers, search, status, canonicalStatus, range]); // Paginated data const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1; @@ -210,12 +222,17 @@ export default function Transfers() {
({ - value: parseFloat(t.sendAmount), - label: t.recipient, - currency: t.from, - }))} - formatValue={(d) => formatAmount(d.value, d.currency)} + data={filteredTransfers.slice(0, 5).map((t) => { + // Bar heights need a float; the label keeps the exact decimal. + const parsed = parseDecimal(t.sendAmount); + return { + value: parsed.ok ? Number(parsed.value) : 0, + amount: parsed.ok ? parsed.value : null, + label: t.recipient, + currency: t.from, + }; + })} + formatValue={(d) => formatMoney(d.amount, d.currency)} /> {pageTransfers.map((t) => ( } rejected + */ +function reportRejected(rejected) { + for (const entry of rejected) { + console.error(entry.diff); + } +} + /** * List all transfers, newest first. - * @returns {Promise} + * + * One malformed record is dropped and logged so the rest of the list still + * renders. A response where *every* record fails is a schema change, not bad + * data, and is raised so the UI can say so instead of showing "no transfers". + * + * @returns {Promise} contract-normalised transfers */ export function listTransfers() { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { setTimeout(() => { - const transfers = read() - .slice() - .sort((a, b) => { - return new Date(b.createdAt) - new Date(a.createdAt); + try { + const { transfers, rejected, breaking } = parseTransferList(read(), { + source: 'listTransfers', }); - resolve(transfers); + if (rejected.length) reportRejected(rejected); + if (breaking) { + throw new ContractViolationError( + transferContract, + rejected.flatMap((entry) => entry.issues), + { source: 'listTransfers' }, + ); + } + resolve( + transfers + .slice() + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), + ); + } catch (error) { + reject(error); + } }, 400); }); } /** * Create a new transfer record. + * + * The assembled record is validated before it is persisted, so a drifted + * payload fails at submission with an actionable diff instead of writing a + * record that later renders as a plausible-looking wrong number. + * * @param {object} payload - transfer details * @returns {Promise} the created transfer */ export function createTransfer(payload) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { setTimeout(() => { - const transfer = { - id: 'tx_' + Date.now(), - status: 'pending', - createdAt: new Date().toISOString(), - ...payload, - }; - const transfers = read(); - transfers.push(transfer); - write(transfers); - resolve(transfer); + try { + const transfer = parseTransfer( + { + id: 'tx_' + Date.now(), + status: 'pending', + createdAt: new Date().toISOString(), + ...payload, + }, + { source: 'createTransfer' }, + ); + const existing = read(); + const transfers = Array.isArray(existing) ? existing : []; + transfers.push(transfer); + write(transfers); + resolve(transfer); + } catch (error) { + reject(error); + } }, 700); }); } diff --git a/src/services/contracts/README.md b/src/services/contracts/README.md new file mode 100644 index 0000000..c24666a --- /dev/null +++ b/src/services/contracts/README.md @@ -0,0 +1,49 @@ +# API contracts + +Every response the app reads is validated here before anything renders it. The +goal is not validation for its own sake — it is that a schema or precision +change from the provider **fails loudly with a diff you can act on**, instead of +quietly becoming a `$0.00` on someone's receipt. + +## Layout + +| File | Purpose | +| ------------- | --------------------------------------------------------------- | +| `schema.js` | The validator: field types, issue codes, and the diff formatter | +| `transfer.js` | `Transfer v1` — the record the transfers list and rows render | +| `quote.js` | `Quote v1` — the priced transfer shown before submission | + +Recorded payloads live in `test/fixtures/v/`, with the ones that must be +rejected in `test/fixtures/v/breaking/`. + +## What counts as breaking + +- **Additive** — a provider adds a field. Allowed and preserved, so a released + client keeps working. +- **Breaking** — a declared field is missing, renamed, retyped, or carries a + value outside its declared set. Rejected with a diff naming the field. + +A snake_case rename is reported as `renamed_field` rather than as an unrelated +missing field, so the diff points at the actual change: + +``` +Transfer v1 contract mismatch from listTransfers[0] (1 issue): + - sendAmount: expected field "sendAmount", received field "send_amount" carrying string "200.00" + hint: "send_amount" looks like a renamed "sendAmount" — map it in the adapter or bump the contract version + Fix: update the adapter for Transfer v1 and the matching fixtures in test/fixtures/v1/ together, or bump the contract version. +``` + +## Changing a contract + +1. Decide whether the change is additive (no version bump) or breaking. +2. For a breaking change, add `test/fixtures/v/`, keeping `v` intact so + the old shape stays covered while both are in flight. +3. Update the schema and the fixtures **in the same commit**. The contract tests + fail if a declared status has no fixture, or if a breaking fixture has no + recorded expectation, so neither half can be forgotten. + +## Money + +Amounts cross this boundary as canonical decimal _strings_ and are never +floats in between — see the header of `src/utils/money.js` for why. Render them +with `formatMoney`, not `formatAmount`. diff --git a/src/services/contracts/quote.js b/src/services/contracts/quote.js new file mode 100644 index 0000000..3ca0cb9 --- /dev/null +++ b/src/services/contracts/quote.js @@ -0,0 +1,51 @@ +// Versioned contract for an FX quote. +// +// Amounts are decimal strings, not floats: a quote is a promise about exact +// numbers, and the receipt has to be able to reproduce them. + +import { defineContract, parseOrThrow } from './schema.js'; + +export const QUOTE_CONTRACT_VERSION = 1; + +/** How long a quote is honoured before it must be rebuilt. */ +export const QUOTE_TTL_MS = 60_000; + +export const quoteContract = defineContract({ + name: 'Quote', + version: QUOTE_CONTRACT_VERSION, + fields: { + version: { type: 'integer', required: true }, + from: { type: 'currency', required: true }, + to: { type: 'currency', required: true }, + rate: { type: 'decimal', required: true, min: 0 }, + sendAmount: { type: 'decimal', required: true, min: 0 }, + fee: { type: 'decimal', required: true, min: 0 }, + amountAfterFee: { type: 'decimal', required: true, min: 0 }, + receiveAmount: { type: 'decimal', required: true, min: 0 }, + createdAt: { type: 'timestamp', required: true }, + expiresAt: { type: 'timestamp', required: true }, + }, +}); + +/** + * Parse a quote payload, throwing an actionable diff if it does not match v1. + * @param {unknown} raw + * @param {{source?: string}} [options] + * @returns {object} + */ +export function parseQuote(raw, options = {}) { + return parseOrThrow(quoteContract, raw, options); +} + +/** + * Has a quote passed its expiry? + * @param {object} quote - a parsed quote + * @param {number|Date} [now] - injectable clock, so tests stay deterministic + * @returns {boolean} + */ +export function isQuoteExpired(quote, now = Date.now()) { + if (!quote?.expiresAt) return false; + const expiry = Date.parse(quote.expiresAt); + if (Number.isNaN(expiry)) return true; + return (now instanceof Date ? now.getTime() : now) >= expiry; +} diff --git a/src/services/contracts/schema.js b/src/services/contracts/schema.js new file mode 100644 index 0000000..cc15c1c --- /dev/null +++ b/src/services/contracts/schema.js @@ -0,0 +1,311 @@ +// A tiny, dependency-free schema validator for API responses. +// +// The point is not general-purpose validation — it is *actionable failure*. +// When a provider renames a field or changes a type, the app should say +// exactly which field moved and what it moved to, instead of quietly +// rendering a zero. Every issue carries the path, what was expected and what +// actually arrived, and `formatIssues` turns that into a diff a reviewer can +// act on without opening a debugger. +// +// Compatibility policy, applied by `validate`: +// * unknown fields are ALLOWED and preserved — providers may add fields +// without breaking a released client (forward compatible); +// * missing, retyped or renamed declared fields are BREAKING and reported. + +import { + compareDecimal, + describeValue, + parseDecimal, +} from '../../utils/money.js'; + +/** @typedef {{path: string, code: string, expected: string, received: string, hint?: string}} ContractIssue */ + +const CURRENCY_PATTERN = /^[A-Z]{3}$/; + +/** + * Normalise a field name so that `send_amount`, `sendAmount` and `SendAmount` + * collapse to the same key. Used to detect renames rather than reporting them + * as an unrelated "missing field". + * @param {string} key + * @returns {string} + */ +function normalizeKey(key) { + return key.toLowerCase().replace(/[_\-\s]/g, ''); +} + +const VALIDATORS = { + string(value, field) { + if (typeof value !== 'string') { + return { expected: 'string', received: describeValue(value) }; + } + if (field.pattern && !field.pattern.test(value)) { + return { + expected: `string matching ${field.pattern}`, + received: describeValue(value), + }; + } + if (field.minLength && value.length < field.minLength) { + return { + expected: `string of at least ${field.minLength} characters`, + received: describeValue(value), + }; + } + return { value }; + }, + + currency(value) { + if (typeof value !== 'string' || !CURRENCY_PATTERN.test(value)) { + return { + expected: 'ISO 4217 currency code (three uppercase letters)', + received: describeValue(value), + }; + } + return { value }; + }, + + // Money and rates. Normalised to a canonical decimal *string* so no float + // ever enters the app from a response body. + decimal(value, field) { + const parsed = parseDecimal(value); + if (!parsed.ok) { + return { + expected: 'decimal (number or numeric string)', + received: describeValue(value), + hint: parsed.error, + }; + } + if (field.min != null && compareDecimal(parsed.value, field.min) < 0) { + return { + expected: `decimal >= ${field.min}`, + received: describeValue(value), + }; + } + return { value: parsed.value }; + }, + + integer(value) { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + return { + expected: 'safe integer', + received: describeValue(value), + }; + } + return { value }; + }, + + boolean(value) { + if (typeof value !== 'boolean') { + return { expected: 'boolean', received: describeValue(value) }; + } + return { value }; + }, + + timestamp(value) { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + return { + expected: 'ISO 8601 timestamp string', + received: describeValue(value), + }; + } + return { value }; + }, + + enum(value, field) { + if (typeof value !== 'string') { + return { + expected: `one of [${field.values.join(', ')}]`, + received: describeValue(value), + }; + } + if (field.values.includes(value)) return { value }; + + const aliased = field.aliases?.[value]; + if (aliased && field.values.includes(aliased)) { + // A known legacy spelling: accept it and normalise, no issue raised. + return { value: aliased }; + } + return { + expected: `one of [${field.values.join(', ')}]`, + received: describeValue(value), + hint: field.aliases + ? `accepted legacy spellings: ${Object.keys(field.aliases).join(', ')}` + : undefined, + }; + }, +}; + +/** + * Declare a versioned contract. + * @param {{name: string, version: number, fields: object}} definition + * @returns {{name: string, version: number, fields: object, id: string}} + */ +export function defineContract(definition) { + const { name, version, fields } = definition; + for (const [key, field] of Object.entries(fields)) { + if (!VALIDATORS[field.type]) { + throw new Error( + `Contract ${name} v${version}: field "${key}" uses unknown type "${field.type}"`, + ); + } + } + return { ...definition, id: `${name} v${version}` }; +} + +/** + * Validate a raw response object against a contract. + * + * @param {object} contract - from defineContract() + * @param {unknown} raw - the value straight off the wire + * @returns {{ok: boolean, value: object|null, issues: ContractIssue[]}} + */ +export function validate(contract, raw) { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { + ok: false, + value: null, + issues: [ + { + path: '(root)', + code: 'not_an_object', + expected: 'object', + received: describeValue(raw), + }, + ], + }; + } + + const issues = []; + const result = {}; + const declaredKeys = Object.keys(contract.fields); + const unknownKeys = Object.keys(raw).filter((k) => !declaredKeys.includes(k)); + + // Index unknown keys by normalised name so a rename is reported as a rename. + const unknownByNormalized = new Map(); + for (const key of unknownKeys) { + unknownByNormalized.set(normalizeKey(key), key); + } + + for (const [key, field] of Object.entries(contract.fields)) { + const present = Object.prototype.hasOwnProperty.call(raw, key); + const value = raw[key]; + + if ( + !present || + value === undefined || + (value === null && !field.nullable) + ) { + if (field.required) { + const renamedFrom = unknownByNormalized.get(normalizeKey(key)); + if (renamedFrom) { + issues.push({ + path: key, + code: 'renamed_field', + expected: `field "${key}"`, + received: `field "${renamedFrom}" carrying ${describeValue(raw[renamedFrom])}`, + hint: `"${renamedFrom}" looks like a renamed "${key}" — map it in the adapter or bump the contract version`, + }); + } else { + issues.push({ + path: key, + code: 'missing_field', + expected: `required ${field.type}`, + received: present ? describeValue(value) : 'absent', + }); + } + continue; + } + if (field.default !== undefined) result[key] = field.default; + else if (value === null && field.nullable) result[key] = null; + continue; + } + + if (value === null && field.nullable) { + result[key] = null; + continue; + } + + const outcome = VALIDATORS[field.type](value, field); + if (outcome.expected) { + issues.push({ + path: key, + code: field.type === 'enum' ? 'invalid_enum' : 'wrong_type', + expected: outcome.expected, + received: outcome.received, + hint: outcome.hint, + }); + continue; + } + result[key] = outcome.value; + } + + // Unknown fields are forward-compatible: keep them, never fail on them. + for (const key of unknownKeys) { + if (!(key in result)) result[key] = raw[key]; + } + + return { + ok: issues.length === 0, + value: issues.length ? null : result, + issues, + }; +} + +/** + * Render issues as a multi-line, actionable diff. + * @param {object} contract + * @param {ContractIssue[]} issues + * @param {{source?: string}} [options] - where the payload came from + * @returns {string} + */ +export function formatIssues(contract, issues, options = {}) { + const source = options.source ? ` from ${options.source}` : ''; + const lines = [ + `${contract.id} contract mismatch${source} (${issues.length} ${ + issues.length === 1 ? 'issue' : 'issues' + }):`, + ]; + for (const issue of issues) { + lines.push( + ` - ${issue.path}: expected ${issue.expected}, received ${issue.received}`, + ); + if (issue.hint) lines.push(` hint: ${issue.hint}`); + } + lines.push( + ` Fix: update the adapter for ${contract.id} and the matching fixtures in test/fixtures/v${contract.version}/ together, or bump the contract version.`, + ); + return lines.join('\n'); +} + +/** + * Thrown when a payload cannot be trusted. The message is the actionable diff; + * `issues` is the structured form for tests and telemetry. + */ +export class ContractViolationError extends Error { + /** + * @param {object} contract + * @param {ContractIssue[]} issues + * @param {{source?: string}} [options] + */ + constructor(contract, issues, options = {}) { + super(formatIssues(contract, issues, options)); + this.name = 'ContractViolationError'; + this.contract = contract.id; + this.version = contract.version; + this.issues = issues; + this.source = options.source; + } +} + +/** + * Validate or throw a ContractViolationError carrying the diff. + * @param {object} contract + * @param {unknown} raw + * @param {{source?: string}} [options] + * @returns {object} the normalised value + */ +export function parseOrThrow(contract, raw, options = {}) { + const outcome = validate(contract, raw); + if (!outcome.ok) + throw new ContractViolationError(contract, outcome.issues, options); + return outcome.value; +} diff --git a/src/services/contracts/transfer.js b/src/services/contracts/transfer.js new file mode 100644 index 0000000..ce4e901 --- /dev/null +++ b/src/services/contracts/transfer.js @@ -0,0 +1,155 @@ +// Versioned contract for a transfer record. +// +// v1 is the shape RemitFlow's UI renders. Anything reaching the app through +// `parseTransfer` has already been checked field by field, so the receipt +// screens can render without defensive coercion. + +import { + ContractViolationError, + defineContract, + formatIssues, + parseOrThrow, + validate, +} from './schema.js'; + +export const TRANSFER_CONTRACT_VERSION = 1; + +/** + * The transfer lifecycle, in order. `completed` is the settled terminal state; + * it keeps its historical name because it is what the UI, the URL query string + * (`?status=completed`) and every stored record already use. + */ +export const TRANSFER_STATUSES = [ + 'quoted', + 'validating', + 'authorizing', + 'pending', + 'completed', + 'failed', + 'expired', +]; + +/** + * Wire spellings accepted from a provider and normalised on the way in. + * Adding an entry here is a compatibility fix; removing one is breaking. + */ +export const TRANSFER_STATUS_ALIASES = { + settled: 'completed', + succeeded: 'completed', + success: 'completed', + submitted: 'pending', + processing: 'pending', + validation_pending: 'validating', + authorization_required: 'authorizing', +}; + +/** Statuses that will not change again. */ +export const TERMINAL_STATUSES = ['completed', 'failed', 'expired']; + +export const transferContract = defineContract({ + name: 'Transfer', + version: TRANSFER_CONTRACT_VERSION, + fields: { + id: { type: 'string', required: true, minLength: 1 }, + recipient: { type: 'string', required: true, minLength: 1 }, + from: { type: 'currency', required: true }, + to: { type: 'currency', required: true }, + sendAmount: { type: 'decimal', required: true, min: 0 }, + receiveAmount: { type: 'decimal', required: true, min: 0 }, + status: { + type: 'enum', + required: true, + values: TRANSFER_STATUSES, + aliases: TRANSFER_STATUS_ALIASES, + }, + createdAt: { type: 'timestamp', required: true }, + // Optional enrichment. Absent on legacy records, so never required. + fee: { type: 'decimal', required: false, min: 0 }, + rate: { type: 'decimal', required: false, min: 0 }, + expiresAt: { type: 'timestamp', required: false }, + failureReason: { type: 'string', required: false, nullable: true }, + }, +}); + +/** + * Parse one transfer, throwing an actionable diff if it does not match v1. + * @param {unknown} raw + * @param {{source?: string}} [options] + * @returns {object} normalised transfer (amounts are decimal strings) + */ +export function parseTransfer(raw, options = {}) { + return parseOrThrow(transferContract, raw, options); +} + +/** + * Parse a list of transfers without letting one bad record hide the rest. + * + * A single corrupt row is a data problem: drop it and carry on. Every row + * failing is a schema change: that is reported through `breaking` so callers + * can surface it instead of rendering a convincing empty state. + * + * @param {unknown} raw - the response body + * @param {{source?: string}} [options] + * @returns {{transfers: object[], rejected: Array<{index: number, issues: object[], diff: string}>, breaking: boolean}} + */ +export function parseTransferList(raw, options = {}) { + if (!Array.isArray(raw)) { + throw new ContractViolationError( + transferContract, + [ + { + path: '(root)', + code: 'not_an_array', + expected: 'array of Transfer', + received: raw === null ? 'null' : typeof raw, + }, + ], + options, + ); + } + + const transfers = []; + const rejected = []; + + raw.forEach((item, index) => { + const outcome = validate(transferContract, item); + if (outcome.ok) { + transfers.push(outcome.value); + return; + } + rejected.push({ + index, + issues: outcome.issues, + diff: formatIssues(transferContract, outcome.issues, { + source: `${options.source ?? 'response'}[${index}]`, + }), + }); + }); + + return { + transfers, + rejected, + breaking: raw.length > 0 && transfers.length === 0, + }; +} + +/** + * Normalise a status string the way the contract does, for callers that hold + * a bare status (a URL query parameter, a filter dropdown) rather than a + * whole record. + * @param {string} status + * @returns {string|null} canonical status, or null if unrecognised + */ +export function normalizeStatus(status) { + if (typeof status !== 'string') return null; + if (TRANSFER_STATUSES.includes(status)) return status; + return TRANSFER_STATUS_ALIASES[status] ?? null; +} + +/** + * @param {string} status + * @returns {boolean} + */ +export function isTerminalStatus(status) { + return TERMINAL_STATUSES.includes(normalizeStatus(status)); +} diff --git a/src/services/fx.js b/src/services/fx.js index 1d6d8da..a38a24e 100644 --- a/src/services/fx.js +++ b/src/services/fx.js @@ -1,28 +1,64 @@ // Mock FX rate service. In production these rates would come from the backend // or an on-chain Stellar DEX path-payment quote. +// +// Rates are held as decimal *strings* and cross rates are derived by exact +// decimal division. Quoting EUR->NGN as `1480.5 / 0.92` in binary floating +// point yields 1609.2391304347825, which is already wrong in the last place; +// at NGN scale that error is visible on a receipt. + +import { divideDecimal, parseDecimal } from '../utils/money.js'; // Rates expressed relative to 1 USD. const USD_RATES = { - USD: 1, - EUR: 0.92, - GBP: 0.79, - NGN: 1480.5, - INR: 83.2, - PHP: 58.4, - MXN: 17.1, + USD: '1', + EUR: '0.92', + GBP: '0.79', + NGN: '1480.5', + INR: '83.2', + PHP: '58.4', + MXN: '17.1', }; -export function getRate(from, to) { +/** + * Cross rate as an exact decimal string: units of `to` per 1 unit of `from`. + * @param {string} from - source currency code + * @param {string} to - destination currency code + * @returns {string|null} decimal string, or null when the pair is unsupported + */ +export function getRateDecimal(from, to) { const fromRate = USD_RATES[from]; const toRate = USD_RATES[to]; if (!fromRate || !toRate) return null; - return toRate / fromRate; + if (from === to) return '1'; + return divideDecimal(toRate, fromRate); +} + +/** + * Cross rate as a number. Convenient for display and charting; do not use it + * for money arithmetic — use getRateDecimal() with the helpers in utils/money. + * @param {string} from + * @param {string} to + * @returns {number|null} + */ +export function getRate(from, to) { + const decimal = getRateDecimal(from, to); + return decimal == null ? null : Number(decimal); } +/** + * Convert an amount between currencies. + * @param {number|string} amount + * @param {string} from + * @param {string} to + * @returns {number|null} null when the pair is unsupported or the amount is + * not a parseable decimal — never a silently coerced zero + */ export function convert(amount, from, to) { - const rate = getRate(from, to); + const rate = getRateDecimal(from, to); if (rate == null) return null; - return Number(amount) * rate; + const parsed = parseDecimal(amount); + if (!parsed.ok) return null; + return Number(parsed.value) * Number(rate); } /** diff --git a/src/services/quote.js b/src/services/quote.js index 479497b..dbec364 100644 --- a/src/services/quote.js +++ b/src/services/quote.js @@ -1,42 +1,100 @@ // Quote service: combines FX rates and fees into a full transfer quote. -import { getRate, convert } from './fx.js'; +// +// All arithmetic runs on integer minor units. The old implementation chained +// float operations (percentOf -> roundTo -> subtract -> multiply), so the +// receive amount carried accumulated binary error and the stored value did not +// always equal the quoted one. Here every intermediate is exact and every +// output is quantized to the real minor unit of its currency. + +import { getRateDecimal } from './fx.js'; import { FEE_PERCENT, FLAT_FEE, MIN_FEE } from '../constants/fees.js'; -import { percentOf, roundTo } from '../utils/math.js'; +import { + QUOTE_CONTRACT_VERSION, + QUOTE_TTL_MS, + parseQuote, +} from './contracts/quote.js'; +import { + convertMinorUnits, + currencyExponent, + fromMinorUnits, + parseDecimal, + scaleMinorUnits, + toMinorUnits, +} from '../utils/money.js'; /** * Calculate the total fee (in the source currency) for a given send amount. - * @param {number} amount - amount being sent in the source currency - * @returns {number} the fee in the source currency + * @param {number|string} amount - amount being sent in the source currency + * @param {string} [currency] - source currency, for minor-unit precision + * @returns {string|null} fee as a decimal string, or null if `amount` is not + * a parseable decimal */ -export function calculateFee(amount) { - const num = Number(amount) || 0; - const fee = percentOf(num, FEE_PERCENT) + FLAT_FEE; - return roundTo(Math.max(fee, MIN_FEE)); +export function calculateFee(amount, currency = 'USD') { + const parsed = parseDecimal(amount); + if (!parsed.ok) return null; + + const exponent = currencyExponent(currency); + const sendMinor = toMinorUnits(parsed.value, exponent); + const percentMinor = scaleMinorUnits(sendMinor, FEE_PERCENT); + const flatMinor = toMinorUnits(FLAT_FEE, exponent); + const minMinor = toMinorUnits(MIN_FEE, exponent); + + const feeMinor = percentMinor + flatMinor; + return fromMinorUnits(feeMinor > minMinor ? feeMinor : minMinor, exponent); } /** * Build a full quote for a transfer. - * @param {number} amount - amount to send in the source currency + * + * @param {number|string} amount - amount to send in the source currency * @param {string} from - source currency code * @param {string} to - destination currency code - * @returns {object|null} quote breakdown or null if the pair is unsupported + * @param {{now?: number|Date, ttlMs?: number}} [options] - injectable clock so + * quote expiry is deterministic under test + * @returns {object|null} a Quote v1 payload, or null when the pair is + * unsupported or the amount is not a parseable decimal */ -export function buildQuote(amount, from, to) { - const rate = getRate(from, to); +export function buildQuote(amount, from, to, options = {}) { + const rate = getRateDecimal(from, to); if (rate == null) return null; - const sendAmount = Number(amount) || 0; - const fee = calculateFee(sendAmount); - const amountAfterFee = Math.max(sendAmount - fee, 0); - const receiveAmount = convert(amountAfterFee, from, to); + const parsed = parseDecimal(amount); + if (!parsed.ok) return null; - return { - from, - to, + const fromExponent = currencyExponent(from); + const toExponent = currencyExponent(to); + + const sendMinor = toMinorUnits(parsed.value, fromExponent); + const feeMinor = toMinorUnits(calculateFee(parsed.value, from), fromExponent); + const afterFeeMinor = sendMinor > feeMinor ? sendMinor - feeMinor : 0n; + const receiveMinor = convertMinorUnits( + afterFeeMinor, rate, - sendAmount, - fee, - amountAfterFee, - receiveAmount, - }; + fromExponent, + toExponent, + ); + + const nowMs = + options.now instanceof Date + ? options.now.getTime() + : (options.now ?? Date.now()); + const ttlMs = options.ttlMs ?? QUOTE_TTL_MS; + + // Round-trip through the contract so a quote is validated at the point it is + // produced, not only at the point it is consumed. + return parseQuote( + { + version: QUOTE_CONTRACT_VERSION, + from, + to, + rate, + sendAmount: fromMinorUnits(sendMinor, fromExponent), + fee: fromMinorUnits(feeMinor, fromExponent), + amountAfterFee: fromMinorUnits(afterFeeMinor, fromExponent), + receiveAmount: fromMinorUnits(receiveMinor, toExponent), + createdAt: new Date(nowMs).toISOString(), + expiresAt: new Date(nowMs + ttlMs).toISOString(), + }, + { source: 'buildQuote' }, + ); } diff --git a/src/utils/format.js b/src/utils/format.js index c8a61a7..8d5a3db 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -1,8 +1,15 @@ // Formatting helpers for currency, dates and addresses. import { DEFAULT_LOCALE } from '../constants/locales.js'; +import { parseDecimal, quantize } from './money.js'; /** * Format an amount as a currency string. + * + * Low-level helper: it assumes two decimal places and falls back to 0 for a + * non-numeric amount. For money that arrived from an API use `formatMoney` + * in utils/money.js instead, which honours the currency's real minor unit and + * refuses to render an unparseable value as "0.00". + * * @param {number|string} amount - the amount to format * @param {string} [currency] - ISO currency code, e.g. "USD" * @param {string} [locale] - BCP 47 locale tag used for grouping, decimal @@ -41,30 +48,47 @@ export function formatDate(value, locale = DEFAULT_LOCALE) { /** * Format an exchange rate as a "1 FROM = X TO" string. - * @param {number} rate + * Accepts the decimal strings that the FX and quote services now produce as + * well as plain numbers; anything unparseable renders as "-". + * @param {number|string} rate * @param {string} from * @param {string} to * @returns {string} */ export function formatRate(rate, from, to) { - if (rate == null) return '-'; - return `1 ${from} = ${rate.toFixed(4)} ${to}`; + const parsed = parseDecimal(rate); + if (!parsed.ok) return '-'; + return `1 ${from} = ${Number(parsed.value).toFixed(4)} ${to}`; } /** - * Normalise a raw amount string into a clean, fixed-precision value. - * Strips non-numeric characters and clamps to two decimal places so the - * amount field shows a tidy value (e.g. "1,234.5" -> "1234.50"). - * @param {string} value - the raw input value - * @returns {string} the cleaned amount, or '' if the input has no digits + * Normalise a raw amount string into a clean, fixed-precision value for the + * amount field (e.g. "1,234.5" -> "1234.50"). + * + * The value is parsed first and only stripped of grouping characters as a + * fallback. Stripping first corrupts perfectly valid input: `` accepts "1e3", and the old strip-then-parse implementation + * turned that into "13.00" — a 1000x under-send with no error anywhere. A + * leading minus is likewise preserved so a negative amount reaches validation + * as negative instead of being silently flipped positive. + * + * @param {string|number} value - the raw input value + * @param {string} [currency] - used for minor-unit precision + * @returns {string} the cleaned amount, or '' if the input is not a number */ -export function formatCurrencyInput(value) { +export function formatCurrencyInput(value, currency = 'USD') { if (value == null) return ''; - const cleaned = String(value).replace(/[^0-9.]/g, ''); - if (cleaned === '' || cleaned === '.') return ''; - const num = Number(cleaned); - if (!Number.isFinite(num)) return ''; - return num.toFixed(2); + + let parsed = parseDecimal(value); + if (!parsed.ok) { + // Fall back to stripping display formatting (grouping separators, + // currency symbols, whitespace) while keeping sign and decimal point. + const stripped = String(value).replace(/[^0-9.eE+-]/g, ''); + parsed = parseDecimal(stripped); + } + if (!parsed.ok) return ''; + + return quantize(parsed.value, currency); } /** diff --git a/src/utils/money.js b/src/utils/money.js new file mode 100644 index 0000000..914539c --- /dev/null +++ b/src/utils/money.js @@ -0,0 +1,417 @@ +// Exact money handling for RemitFlow. +// +// Two problems this module exists to solve: +// +// 1. Silent numeric coercion. `Number(x)` maps `null`, `''`, `[]` and `false` +// to 0 and anything else to NaN, which the old `Number(x) || 0` idiom then +// also turned into 0. A transfer whose amount failed to parse rendered as +// "$0.00" — a plausible-looking number that misrepresents the outcome. +// `parseDecimal` refuses to guess: it either returns a value or an error. +// +// 2. Binary floating point. 0.1 + 0.2 !== 0.3, and an FX rate multiplication +// chained through floats accumulates error that eventually shows up as an +// off-by-one-cent receipt. Every amount here is carried as a decimal +// *string* and every arithmetic step runs on BigInt minor units, so the +// numbers on a receipt are exactly the numbers that were quoted. +// +// Money crosses the API boundary as a decimal string and stays a decimal +// string until it is formatted. It is never a float in between. + +import { DEFAULT_LOCALE } from '../constants/locales.js'; + +// Placeholder rendered instead of a fabricated "0.00" when a value cannot be +// parsed. Showing nothing is safer than showing the wrong number. +export const MONEY_PLACEHOLDER = '—'; + +// Working precision for exchange rates. Twelve decimal places is far beyond +// any published FX quote and keeps cross-rate division loss below a millionth +// of a minor unit for the corridors RemitFlow supports. +export const RATE_SCALE = 12; + +// ISO 4217 currencies whose minor unit is not 1/100. +const ZERO_DECIMAL_CURRENCIES = new Set([ + 'BIF', + 'CLP', + 'DJF', + 'GNF', + 'ISK', + 'JPY', + 'KMF', + 'KRW', + 'PYG', + 'RWF', + 'UGX', + 'UYI', + 'VND', + 'VUV', + 'XAF', + 'XOF', + 'XPF', +]); + +const THREE_DECIMAL_CURRENCIES = new Set([ + 'BHD', + 'IQD', + 'JOD', + 'KWD', + 'LYD', + 'OMR', + 'TND', +]); + +// A decimal literal, optionally signed, optionally in exponent notation. +// Deliberately stricter than Number(): no whitespace-only strings, no +// hex/octal/binary literals, no "Infinity", no numeric separators. +const DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +/** + * Number of decimal places in a currency's minor unit. + * @param {string} code - ISO 4217 currency code + * @returns {number} 0, 2 or 3 + */ +export function currencyExponent(code) { + if (typeof code !== 'string') return 2; + const upper = code.toUpperCase(); + if (ZERO_DECIMAL_CURRENCIES.has(upper)) return 0; + if (THREE_DECIMAL_CURRENCIES.has(upper)) return 3; + return 2; +} + +/** + * Rewrite exponent notation as a plain decimal literal ("1e3" -> "1000"). + * @param {string} input + * @returns {string} + */ +function expandExponent(input) { + const match = /^([+-]?)(\d*)(?:\.(\d*))?[eE]([+-]?\d+)$/.exec(input); + if (!match) return input; + + const sign = match[1]; + const intPart = match[2] || ''; + const fracPart = match[3] || ''; + const exponent = Number.parseInt(match[4], 10); + const digits = intPart + fracPart; + const pointIndex = intPart.length + exponent; + + if (pointIndex <= 0) { + return `${sign}0.${'0'.repeat(-pointIndex)}${digits}`; + } + if (pointIndex >= digits.length) { + return `${sign}${digits}${'0'.repeat(pointIndex - digits.length)}`; + } + return `${sign}${digits.slice(0, pointIndex)}.${digits.slice(pointIndex)}`; +} + +/** + * Reduce a decimal literal to one canonical spelling so that equal values + * compare equal as strings: no leading "+", no redundant zeros, no "-0". + * @param {string} input - a plain (non-exponent) decimal literal + * @returns {string} + */ +function canonicalize(input) { + let rest = input; + let sign = ''; + if (rest.startsWith('+')) rest = rest.slice(1); + else if (rest.startsWith('-')) { + sign = '-'; + rest = rest.slice(1); + } + + const dot = rest.indexOf('.'); + let intPart = dot === -1 ? rest : rest.slice(0, dot); + let fracPart = dot === -1 ? '' : rest.slice(dot + 1); + + intPart = intPart.replace(/^0+(?=\d)/, ''); + if (intPart === '') intPart = '0'; + fracPart = fracPart.replace(/0+$/, ''); + + const magnitude = fracPart ? `${intPart}.${fracPart}` : intPart; + return magnitude === '0' ? '0' : `${sign}${magnitude}`; +} + +/** + * Describe a rejected value well enough to debug it from a log line. + * @param {unknown} value + * @returns {string} + */ +export function describeValue(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return `array(${value.length})`; + const type = typeof value; + if (type === 'string') return `string ${JSON.stringify(value)}`; + if (type === 'number' || type === 'boolean' || type === 'bigint') { + return `${type} ${String(value)}`; + } + if (type === 'object') { + const keys = Object.keys(value).slice(0, 4).join(', '); + return `object {${keys}}`; + } + return type; +} + +/** + * Parse a value into a canonical decimal string without ever guessing. + * + * Unlike Number(), this rejects null, undefined, booleans, empty strings, + * arrays, objects, NaN and Infinity rather than coercing them to 0 or NaN. + * + * @param {unknown} value - a number or a numeric string + * @returns {{ok: true, value: string} | {ok: false, error: string}} + */ +export function parseDecimal(value) { + if (typeof value === 'bigint') { + return { ok: true, value: canonicalize(value.toString()) }; + } + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return { + ok: false, + error: `expected a finite number, received ${describeValue(value)}`, + }; + } + return { ok: true, value: canonicalize(expandExponent(String(value))) }; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed === '') { + return { ok: false, error: 'expected a decimal string, received ""' }; + } + if (!DECIMAL_PATTERN.test(trimmed)) { + return { + ok: false, + error: `expected a decimal string, received ${describeValue(value)}`, + }; + } + return { ok: true, value: canonicalize(expandExponent(trimmed)) }; + } + + return { + ok: false, + error: `expected a number or decimal string, received ${describeValue(value)}`, + }; +} + +/** + * Parse a decimal, or throw. Use at trust boundaries that have already been + * validated; prefer parseDecimal() where a caller can recover. + * @param {unknown} value + * @param {string} [label] - included in the thrown message + * @returns {string} canonical decimal string + */ +export function requireDecimal(value, label = 'value') { + const parsed = parseDecimal(value); + if (!parsed.ok) throw new TypeError(`${label}: ${parsed.error}`); + return parsed.value; +} + +function pow10(exponent) { + return 10n ** BigInt(exponent); +} + +/** + * Divide two BigInts, rounding halves away from zero (ROUND_HALF_UP). + * @param {bigint} numerator + * @param {bigint} denominator + * @returns {bigint} + */ +function divideHalfUp(numerator, denominator) { + const negative = numerator < 0n !== denominator < 0n; + const absNumerator = numerator < 0n ? -numerator : numerator; + const absDenominator = denominator < 0n ? -denominator : denominator; + const quotient = absNumerator / absDenominator; + const remainder = absNumerator % absDenominator; + const rounded = remainder * 2n >= absDenominator ? quotient + 1n : quotient; + return negative ? -rounded : rounded; +} + +/** + * Convert a decimal string into integer minor units (cents, kobo, ...). + * Extra precision is rounded half-up away from zero. + * @param {string|number} decimal - a value accepted by parseDecimal + * @param {number} exponent - decimal places in the minor unit + * @returns {bigint} + */ +export function toMinorUnits(decimal, exponent) { + const canonical = requireDecimal(decimal, 'amount'); + const negative = canonical.startsWith('-'); + const magnitude = negative ? canonical.slice(1) : canonical; + + const dot = magnitude.indexOf('.'); + const intPart = dot === -1 ? magnitude : magnitude.slice(0, dot); + const fracPart = dot === -1 ? '' : magnitude.slice(dot + 1); + + let digits; + let roundUp = false; + if (fracPart.length <= exponent) { + digits = intPart + fracPart.padEnd(exponent, '0'); + } else { + digits = intPart + fracPart.slice(0, exponent); + // '5' is char code 53; anything at or above it rounds the magnitude up. + roundUp = fracPart.charCodeAt(exponent) >= 53; + } + + let units = BigInt(digits === '' ? '0' : digits); + if (roundUp) units += 1n; + return negative ? -units : units; +} + +/** + * Render integer minor units as a fixed-scale decimal string ("20000" at + * exponent 2 becomes "200.00"). Trailing zeros are kept: the scale carries + * information about the currency. + * @param {bigint} units + * @param {number} exponent + * @returns {string} + */ +export function fromMinorUnits(units, exponent) { + const negative = units < 0n; + const digits = (negative ? -units : units) + .toString() + .padStart(exponent + 1, '0'); + const magnitude = + exponent === 0 + ? digits + : `${digits.slice(0, -exponent)}.${digits.slice(-exponent)}`; + return negative ? `-${magnitude}` : magnitude; +} + +/** + * Multiply minor units by a decimal factor, rounding half-up to whole units. + * Used for percentage fees, where the factor is not a currency amount. + * @param {bigint} units + * @param {string|number} factor - e.g. "0.005" for a 0.5% fee + * @returns {bigint} + */ +export function scaleMinorUnits(units, factor) { + const scaledFactor = toMinorUnits( + requireDecimal(factor, 'factor'), + RATE_SCALE, + ); + return divideHalfUp(units * scaledFactor, pow10(RATE_SCALE)); +} + +/** + * Apply an exchange rate to minor units, crossing between currencies whose + * minor units may have different precision. + * @param {bigint} units - amount in the source currency's minor units + * @param {string|number} rate - units of `to` per 1 unit of `from` + * @param {number} fromExponent + * @param {number} toExponent + * @returns {bigint} amount in the destination currency's minor units + */ +export function convertMinorUnits(units, rate, fromExponent, toExponent) { + const scaledRate = toMinorUnits(requireDecimal(rate, 'rate'), RATE_SCALE); + const exponentDelta = toExponent - fromExponent; + const numerator = units * scaledRate * pow10(Math.max(exponentDelta, 0)); + const denominator = pow10(RATE_SCALE + Math.max(-exponentDelta, 0)); + return divideHalfUp(numerator, denominator); +} + +/** + * Divide two decimal strings to `RATE_SCALE` places. Used to derive a cross + * rate from two USD-quoted rates without a float round trip. + * @param {string|number} numerator + * @param {string|number} denominator + * @returns {string|null} canonical decimal string, or null if dividing by zero + */ +export function divideDecimal(numerator, denominator) { + const top = toMinorUnits(requireDecimal(numerator, 'numerator'), RATE_SCALE); + const bottom = toMinorUnits( + requireDecimal(denominator, 'denominator'), + RATE_SCALE, + ); + if (bottom === 0n) return null; + return canonicalize( + fromMinorUnits(divideHalfUp(top * pow10(RATE_SCALE), bottom), RATE_SCALE), + ); +} + +/** + * Round a decimal to a currency's minor unit and return it as a fixed-scale + * decimal string. This is the value that should be stored and displayed. + * @param {string|number} decimal + * @param {string} currency + * @returns {string} + */ +export function quantize(decimal, currency) { + const exponent = currencyExponent(currency); + return fromMinorUnits(toMinorUnits(decimal, exponent), exponent); +} + +/** + * Number of digits after the decimal point in a canonical decimal string. + * @param {string} canonical + * @returns {number} + */ +function fractionDigits(canonical) { + const dot = canonical.indexOf('.'); + return dot === -1 ? 0 : canonical.length - dot - 1; +} + +/** + * Compare two decimal values exactly, at whatever precision they carry. + * Comparing at a fixed scale would quietly call 0.30000000000000004 equal to + * 0.3, which is the class of bug this module exists to remove. + * @param {string|number} a + * @param {string|number} b + * @returns {number} -1, 0 or 1 + */ +export function compareDecimal(a, b) { + const canonicalA = requireDecimal(a, 'a'); + const canonicalB = requireDecimal(b, 'b'); + const scale = Math.max( + fractionDigits(canonicalA), + fractionDigits(canonicalB), + ); + const left = toMinorUnits(canonicalA, scale); + const right = toMinorUnits(canonicalB, scale); + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +/** + * Format a money value for display, using the currency's real minor-unit + * precision (¥1,235 rather than ¥1,234.50). + * + * Unparseable input renders as MONEY_PLACEHOLDER rather than a fabricated + * zero — see the note at the top of this file. + * + * @param {unknown} value - decimal string or number + * @param {string} [currency] - ISO 4217 code + * @param {string} [locale] - BCP 47 locale tag + * @param {{fallback?: string}} [options] + * @returns {string} + */ +export function formatMoney( + value, + currency = 'USD', + locale = DEFAULT_LOCALE, + options = {}, +) { + const fallback = options.fallback ?? MONEY_PLACEHOLDER; + const parsed = parseDecimal(value); + if (!parsed.ok) return fallback; + + const exponent = currencyExponent(currency); + const quantized = fromMinorUnits( + toMinorUnits(parsed.value, exponent), + exponent, + ); + + try { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: exponent, + maximumFractionDigits: exponent, + // Intl accepts a decimal string and formats it without a float round + // trip, which matters for large minor-unit amounts (NGN, VND). + }).format(quantized); + } catch { + return fallback; + } +} diff --git a/src/utils/validate.js b/src/utils/validate.js index e254294..6db572d 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -1,8 +1,10 @@ // Simple validation helpers for the Send Money form. +import { compareDecimal, parseDecimal } from './money.js'; export function isPositiveAmount(value) { - const num = Number(value); - return Number.isFinite(num) && num > 0; + const parsed = parseDecimal(value); + if (!parsed.ok) return false; + return compareDecimal(parsed.value, 0) > 0; } export function isEmail(value) { @@ -19,12 +21,18 @@ export function validateRecipient(value) { /** * Check that an amount does not exceed the available balance. + * + * Compared exactly rather than as floats: at the boundary, `0.1 + 0.2 <= 0.3` + * is false in binary floating point, which would reject a spend of exactly the + * whole balance. + * * @param {number|string} amount - * @param {number} balance - * @returns {boolean} + * @param {number|string} balance + * @returns {boolean} false when either value is not a parseable decimal */ export function isWithinBalance(amount, balance) { - const num = Number(amount); - if (!Number.isFinite(num)) return false; - return num <= Number(balance); + const parsedAmount = parseDecimal(amount); + const parsedBalance = parseDecimal(balance); + if (!parsedAmount.ok || !parsedBalance.ok) return false; + return compareDecimal(parsedAmount.value, parsedBalance.value) <= 0; } diff --git a/test/fixtures/index.js b/test/fixtures/index.js new file mode 100644 index 0000000..96f458e --- /dev/null +++ b/test/fixtures/index.js @@ -0,0 +1,103 @@ +// Versioned API fixtures. +// +// Every supported response shape lives on disk as JSON under +// `test/fixtures/v/`, so a contract change has to be made in two places at +// once: the schema and the recorded payloads. Fixtures are discovered from the +// directory rather than listed here, so adding a state means adding a file and +// nothing else — and deleting a state's fixture makes its test disappear +// loudly rather than silently. +// +// v1/ payloads that MUST parse +// v1/breaking/ payloads that MUST be rejected with an actionable diff +// +// Nothing here touches the network or the clock. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FIXTURE_ROOT = path.dirname(fileURLToPath(import.meta.url)); + +/** Contract versions with a recorded fixture set. */ +export const SUPPORTED_FIXTURE_VERSIONS = fs + .readdirSync(FIXTURE_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^v\d+$/.test(entry.name)) + .map((entry) => Number(entry.name.slice(1))) + .sort((a, b) => a - b); + +function directoryFor(version, kind) { + return kind === 'breaking' + ? path.join(FIXTURE_ROOT, `v${version}`, 'breaking') + : path.join(FIXTURE_ROOT, `v${version}`); +} + +/** + * List fixture names for a version. + * @param {number} version + * @param {{kind?: 'valid'|'breaking', prefix?: string}} [options] + * @returns {string[]} file names without the .json extension, sorted + */ +export function listFixtures(version, options = {}) { + const { kind = 'valid', prefix } = options; + const dir = directoryFor(version, kind); + return fs + .readdirSync(dir) + .filter((name) => name.endsWith('.json')) + .map((name) => name.replace(/\.json$/, '')) + .filter((name) => !prefix || name.startsWith(prefix)) + .sort(); +} + +/** + * Read a fixture. Returns a fresh deep copy each call so a test that mutates + * a payload cannot leak into the next one. + * @param {number} version + * @param {string} name - fixture name without the .json extension + * @param {{kind?: 'valid'|'breaking'}} [options] + * @returns {object} + */ +export function loadFixture(version, name, options = {}) { + const kind = options.kind ?? 'valid'; + const file = path.join(directoryFor(version, kind), `${name}.json`); + if (!fs.existsSync(file)) { + throw new Error( + `Fixture "${name}" not found for contract v${version} (${kind}). ` + + `Available: ${listFixtures(version, { kind }).join(', ')}`, + ); + } + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function loadAll(version, options) { + return listFixtures(version, options).map((name) => ({ + name, + payload: loadFixture(version, name, options), + })); +} + +/** + * All valid transfer fixtures for a version. + * @param {number} version + * @returns {Array<{name: string, payload: object}>} + */ +export function loadTransferFixtures(version) { + return loadAll(version, { prefix: 'transfer.' }); +} + +/** + * All valid quote fixtures for a version. + * @param {number} version + * @returns {Array<{name: string, payload: object}>} + */ +export function loadQuoteFixtures(version) { + return loadAll(version, { prefix: 'quote.' }); +} + +/** + * All fixtures that must be rejected, for a version. + * @param {number} version + * @returns {Array<{name: string, payload: object}>} + */ +export function loadBreakingFixtures(version) { + return loadAll(version, { kind: 'breaking' }); +} diff --git a/test/fixtures/v1/breaking/quote.formatted-rate.json b/test/fixtures/v1/breaking/quote.formatted-rate.json new file mode 100644 index 0000000..57e1d28 --- /dev/null +++ b/test/fixtures/v1/breaking/quote.formatted-rate.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "from": "USD", + "to": "NGN", + "rate": "1,480.50", + "sendAmount": "200.00", + "fee": "1.10", + "amountAfterFee": "198.90", + "receiveAmount": "294471.45", + "createdAt": "2026-08-01T10:00:00Z", + "expiresAt": "2026-08-01T10:01:00Z" +} diff --git a/test/fixtures/v1/breaking/quote.missing-expiry.json b/test/fixtures/v1/breaking/quote.missing-expiry.json new file mode 100644 index 0000000..c2d7ee2 --- /dev/null +++ b/test/fixtures/v1/breaking/quote.missing-expiry.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "from": "USD", + "to": "NGN", + "rate": "1480.5", + "sendAmount": "200.00", + "fee": "1.10", + "amountAfterFee": "198.90", + "receiveAmount": "294471.45", + "createdAt": "2026-08-01T10:00:00Z" +} diff --git a/test/fixtures/v1/breaking/transfer.epoch-timestamp.json b/test/fixtures/v1/breaking/transfer.epoch-timestamp.json new file mode 100644 index 0000000..64b34a8 --- /dev/null +++ b/test/fixtures/v1/breaking/transfer.epoch-timestamp.json @@ -0,0 +1,10 @@ +{ + "id": "tx_3005", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "status": "pending", + "createdAt": 1785535200000 +} diff --git a/test/fixtures/v1/breaking/transfer.money-object-amount.json b/test/fixtures/v1/breaking/transfer.money-object-amount.json new file mode 100644 index 0000000..f6cc63f --- /dev/null +++ b/test/fixtures/v1/breaking/transfer.money-object-amount.json @@ -0,0 +1,10 @@ +{ + "id": "tx_3002", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": { "value": "200.00", "currency": "USD" }, + "receiveAmount": { "value": "294471.45", "currency": "NGN" }, + "status": "pending", + "createdAt": "2026-08-01T10:00:00Z" +} diff --git a/test/fixtures/v1/breaking/transfer.non-iso-currency.json b/test/fixtures/v1/breaking/transfer.non-iso-currency.json new file mode 100644 index 0000000..57544be --- /dev/null +++ b/test/fixtures/v1/breaking/transfer.non-iso-currency.json @@ -0,0 +1,10 @@ +{ + "id": "tx_3006", + "recipient": "amina@example.com", + "from": "usd", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "status": "pending", + "createdAt": "2026-08-01T10:00:00Z" +} diff --git a/test/fixtures/v1/breaking/transfer.null-amount.json b/test/fixtures/v1/breaking/transfer.null-amount.json new file mode 100644 index 0000000..25b952e --- /dev/null +++ b/test/fixtures/v1/breaking/transfer.null-amount.json @@ -0,0 +1,10 @@ +{ + "id": "tx_3004", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": null, + "receiveAmount": "294471.45", + "status": "pending", + "createdAt": "2026-08-01T10:00:00Z" +} diff --git a/test/fixtures/v1/breaking/transfer.renamed-amount.json b/test/fixtures/v1/breaking/transfer.renamed-amount.json new file mode 100644 index 0000000..d1634fc --- /dev/null +++ b/test/fixtures/v1/breaking/transfer.renamed-amount.json @@ -0,0 +1,10 @@ +{ + "id": "tx_3001", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "send_amount": "200.00", + "receiveAmount": "294471.45", + "status": "pending", + "createdAt": "2026-08-01T10:00:00Z" +} diff --git a/test/fixtures/v1/breaking/transfer.unknown-status.json b/test/fixtures/v1/breaking/transfer.unknown-status.json new file mode 100644 index 0000000..ebe69c1 --- /dev/null +++ b/test/fixtures/v1/breaking/transfer.unknown-status.json @@ -0,0 +1,10 @@ +{ + "id": "tx_3003", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "status": "in_flight", + "createdAt": "2026-08-01T10:00:00Z" +} diff --git a/test/fixtures/v1/quote.cross-rate.json b/test/fixtures/v1/quote.cross-rate.json new file mode 100644 index 0000000..f829730 --- /dev/null +++ b/test/fixtures/v1/quote.cross-rate.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "from": "EUR", + "to": "NGN", + "rate": "1609.239130434783", + "sendAmount": "100.00", + "fee": "0.60", + "amountAfterFee": "99.40", + "receiveAmount": "159958.37", + "createdAt": "2026-08-01T10:00:00Z", + "expiresAt": "2026-08-01T10:01:00Z" +} diff --git a/test/fixtures/v1/quote.json b/test/fixtures/v1/quote.json new file mode 100644 index 0000000..36da44b --- /dev/null +++ b/test/fixtures/v1/quote.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "from": "USD", + "to": "NGN", + "rate": "1480.5", + "sendAmount": "200.00", + "fee": "1.10", + "amountAfterFee": "198.90", + "receiveAmount": "294471.45", + "createdAt": "2026-08-01T10:00:00Z", + "expiresAt": "2026-08-01T10:01:00Z" +} diff --git a/test/fixtures/v1/transfer.authorizing.json b/test/fixtures/v1/transfer.authorizing.json new file mode 100644 index 0000000..cef6fac --- /dev/null +++ b/test/fixtures/v1/transfer.authorizing.json @@ -0,0 +1,12 @@ +{ + "id": "tx_2003", + "recipient": "GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF", + "from": "USD", + "to": "INR", + "sendAmount": "120.00", + "receiveAmount": "9925.76", + "fee": "0.70", + "rate": "83.2", + "status": "authorizing", + "createdAt": "2026-08-01T10:00:10Z" +} diff --git a/test/fixtures/v1/transfer.completed-legacy.json b/test/fixtures/v1/transfer.completed-legacy.json new file mode 100644 index 0000000..948fdfc --- /dev/null +++ b/test/fixtures/v1/transfer.completed-legacy.json @@ -0,0 +1,10 @@ +{ + "id": "tx_1001", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": 200, + "receiveAmount": 294620, + "status": "completed", + "createdAt": "2026-05-28T10:15:00Z" +} diff --git a/test/fixtures/v1/transfer.expired.json b/test/fixtures/v1/transfer.expired.json new file mode 100644 index 0000000..98af94b --- /dev/null +++ b/test/fixtures/v1/transfer.expired.json @@ -0,0 +1,14 @@ +{ + "id": "tx_2007", + "recipient": "amina@example.com", + "from": "GBP", + "to": "MXN", + "sendAmount": "50.00", + "receiveAmount": "1074.70", + "fee": "0.35", + "rate": "21.645569620253", + "status": "expired", + "failureReason": null, + "createdAt": "2026-08-01T12:00:00Z", + "expiresAt": "2026-08-01T12:01:00Z" +} diff --git a/test/fixtures/v1/transfer.failed.json b/test/fixtures/v1/transfer.failed.json new file mode 100644 index 0000000..be012f0 --- /dev/null +++ b/test/fixtures/v1/transfer.failed.json @@ -0,0 +1,13 @@ +{ + "id": "tx_2006", + "recipient": "unreachable@example.com", + "from": "USD", + "to": "PHP", + "sendAmount": "75.00", + "receiveAmount": "4351.97", + "fee": "0.48", + "rate": "58.4", + "status": "failed", + "failureReason": "Destination account does not have a trustline for the asset", + "createdAt": "2026-08-01T11:00:00Z" +} diff --git a/test/fixtures/v1/transfer.forward-compatible.json b/test/fixtures/v1/transfer.forward-compatible.json new file mode 100644 index 0000000..4a9c784 --- /dev/null +++ b/test/fixtures/v1/transfer.forward-compatible.json @@ -0,0 +1,15 @@ +{ + "id": "tx_2100", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "fee": "1.10", + "rate": "1480.5", + "status": "pending", + "createdAt": "2026-08-01T10:00:00Z", + "settlementNetwork": "stellar", + "corridorRiskScore": 3, + "complianceHold": false +} diff --git a/test/fixtures/v1/transfer.pending.json b/test/fixtures/v1/transfer.pending.json new file mode 100644 index 0000000..5b4c67c --- /dev/null +++ b/test/fixtures/v1/transfer.pending.json @@ -0,0 +1,12 @@ +{ + "id": "tx_2004", + "recipient": "GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF", + "from": "USD", + "to": "INR", + "sendAmount": "120.00", + "receiveAmount": "9925.76", + "fee": "0.70", + "rate": "83.2", + "status": "pending", + "createdAt": "2026-08-01T10:00:20Z" +} diff --git a/test/fixtures/v1/transfer.quoted.json b/test/fixtures/v1/transfer.quoted.json new file mode 100644 index 0000000..bdb0a26 --- /dev/null +++ b/test/fixtures/v1/transfer.quoted.json @@ -0,0 +1,13 @@ +{ + "id": "tx_2001", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "fee": "1.10", + "rate": "1480.5", + "status": "quoted", + "createdAt": "2026-08-01T10:00:00Z", + "expiresAt": "2026-08-01T10:01:00Z" +} diff --git a/test/fixtures/v1/transfer.settled.json b/test/fixtures/v1/transfer.settled.json new file mode 100644 index 0000000..84e344e --- /dev/null +++ b/test/fixtures/v1/transfer.settled.json @@ -0,0 +1,12 @@ +{ + "id": "tx_2005", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "fee": "1.10", + "rate": "1480.5", + "status": "settled", + "createdAt": "2026-08-01T10:05:00Z" +} diff --git a/test/fixtures/v1/transfer.validating.json b/test/fixtures/v1/transfer.validating.json new file mode 100644 index 0000000..f56c3e9 --- /dev/null +++ b/test/fixtures/v1/transfer.validating.json @@ -0,0 +1,12 @@ +{ + "id": "tx_2002", + "recipient": "amina@example.com", + "from": "USD", + "to": "NGN", + "sendAmount": "200.00", + "receiveAmount": "294471.45", + "fee": "1.10", + "rate": "1480.5", + "status": "validating", + "createdAt": "2026-08-01T10:00:05Z" +} diff --git a/test/integration/send-money-precision.test.jsx b/test/integration/send-money-precision.test.jsx new file mode 100644 index 0000000..f5cf076 --- /dev/null +++ b/test/integration/send-money-precision.test.jsx @@ -0,0 +1,247 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/App.jsx'; +import * as api from '../../src/services/api.js'; +import { formatCurrencyInput } from '../../src/utils/format.js'; +import { MONEY_PLACEHOLDER } from '../../src/utils/money.js'; +import { parseTransfer } from '../../src/services/contracts/transfer.js'; + +// Intl separates a currency code from its amount with a non-breaking space. +// Compare on the collapsed form so the expectations stay readable whatever +// Testing Library's normalizer does with it. +const money = (expected) => (content) => + content.replace(/ /g, ' ') === expected; + +async function fillForm(user, { amount, to = 'NGN' } = {}) { + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + const amountField = screen.getByLabelText(/^amount$/i); + await user.type(amountField, amount); + await user.tab(); + await user.selectOptions(screen.getByLabelText(/^to$/i), to); + return amountField; +} + +describe('Send flow — amount parsing regressions', () => { + beforeEach(() => { + window.history.pushState({}, '', '/send'); + localStorage.clear(); + // The mock wallet service rejects 10% of connections at random. Pin it so + // the send flow is deterministic without standing up a live provider. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // The original failure mode: the blur handler stripped every non-digit + // before parsing, so the perfectly valid `` value "1e3" + // became the digits "13" — a 1000x under-send with no error anywhere. + it('normalises exponent notation to its real value, not its digits', () => { + expect(formatCurrencyInput('1e3')).toBe('1000.00'); + expect(formatCurrencyInput('1e3')).not.toBe('13.00'); + }); + + it('keeps a negative amount negative so validation can reject it', async () => { + const user = userEvent.setup(); + render(); + + // "-5" used to be silently rewritten to "5.00" and sent as a real transfer. + await fillForm(user, { amount: '-5' }); + expect(screen.getByLabelText(/^amount$/i)).toHaveValue(-5); + + await user.click(screen.getByRole('button', { name: /review & send/i })); + expect( + await screen.findByText(/enter an amount greater than zero/i), + ).toBeInTheDocument(); + }); + + it('submits the exponent amount that the field actually held', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await fillForm(user, { amount: '1e3' }); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); + expect(createTransfer).toHaveBeenCalledTimes(1); + expect(createTransfer.mock.calls[0][0]).toMatchObject({ + sendAmount: '1000', + }); + }); +}); + +describe('Send flow — the receipt matches the quote', () => { + beforeEach(() => { + window.history.pushState({}, '', '/send'); + localStorage.clear(); + // The mock wallet service rejects 10% of connections at random. Pin it so + // the send flow is deterministic without standing up a live provider. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('records the fee and rate so the receipt can be reproduced', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await fillForm(user, { amount: '200' }); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); + + // 200.00 - 1.10 fee = 198.90, at 1480.5 NGN/USD = 294471.45 exactly. + const payload = createTransfer.mock.calls[0][0]; + expect(payload).toMatchObject({ + from: 'USD', + to: 'NGN', + sendAmount: '200', + fee: '1.1', + rate: '1480.5', + receiveAmount: '294471.45', + }); + expect(payload.expiresAt).toEqual(expect.any(String)); + // The stored record satisfies the contract it will later be read back with. + expect(() => + parseTransfer({ + id: 'tx_check', + status: 'pending', + createdAt: new Date().toISOString(), + ...payload, + }), + ).not.toThrow(); + }); + + it('shows the receipt row with the exact quoted amounts', async () => { + const user = userEvent.setup(); + render(); + + await fillForm(user, { amount: '200' }); + + const quoteCard = (await screen.findByText(/transfer summary/i)).closest( + '.quote-card', + ); + await waitFor(() => { + expect( + within(quoteCard).getByText(money('NGN 294,471.45')), + ).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /review & send/i })); + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); + + // The number on the receipt is the number that was quoted, to the cent. + // The seeded demo transfer shares this recipient, so find the row by the + // amount that was just quoted rather than by position. + const rows = await screen.findAllByRole( + 'group', + { name: /transfer to amina@exam/i }, + { timeout: 5000 }, + ); + const receipt = rows.find((row) => + within(row).queryByText(money('NGN 294,471.45')), + ); + expect(receipt).toBeDefined(); + expect(within(receipt).getByText('$200.00')).toBeInTheDocument(); + expect( + within(receipt).queryByText(MONEY_PLACEHOLDER), + ).not.toBeInTheDocument(); + }); +}); + +describe('Send flow — contract failures do not submit', () => { + beforeEach(() => { + window.history.pushState({}, '', '/send'); + localStorage.clear(); + // The mock wallet service rejects 10% of connections at random. Pin it so + // the send flow is deterministic without standing up a live provider. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reports a rejected payload distinctly from a transient failure', async () => { + const { ContractViolationError } = + await import('../../src/services/contracts/schema.js'); + const { transferContract } = + await import('../../src/services/contracts/transfer.js'); + vi.spyOn(api, 'createTransfer').mockRejectedValueOnce( + new ContractViolationError( + transferContract, + [ + { + path: 'sendAmount', + code: 'wrong_type', + expected: 'decimal (number or numeric string)', + received: 'object {value}', + }, + ], + { source: 'createTransfer' }, + ), + ); + + const user = userEvent.setup(); + render(); + + await fillForm(user, { amount: '200' }); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + expect( + await screen.findByText(/rejected before it was sent/i, undefined, { + timeout: 5000, + }), + ).toBeInTheDocument(); + // Nothing was submitted, so the user stays on the form and can correct it. + expect( + screen.getByRole('button', { name: /review & send/i }), + ).toBeEnabled(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('sendAmount'), + ); + }); + + it('explains an unquotable transfer instead of doing nothing', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.type(screen.getByLabelText(/^amount$/i), '50'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + + // An unsupported corridor: buildQuote returns null and the old code + // returned silently, leaving an enabled button and no explanation. + const quote = await import('../../src/services/quote.js'); + vi.spyOn(quote, 'buildQuote').mockReturnValue(null); + + await user.click(screen.getByRole('button', { name: /review & send/i })); + + expect( + await screen.findByText(/could not price this transfer/i, undefined, { + timeout: 5000, + }), + ).toBeInTheDocument(); + expect(createTransfer).not.toHaveBeenCalled(); + }); +}); diff --git a/test/integration/transfer-states.test.jsx b/test/integration/transfer-states.test.jsx new file mode 100644 index 0000000..58812f0 --- /dev/null +++ b/test/integration/transfer-states.test.jsx @@ -0,0 +1,174 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/App.jsx'; +import { TRANSFER_STATUS_LABELS } from '../../src/components/StatusBadge.jsx'; +import { + TRANSFER_CONTRACT_VERSION, + TRANSFER_STATUSES, +} from '../../src/services/contracts/transfer.js'; +import { loadFixture, loadTransferFixtures } from '../fixtures/index.js'; + +const V1 = TRANSFER_CONTRACT_VERSION; + +// Every fixture, dated inside the widest date-range preset so the default +// (unfiltered) view shows them all. No network, no live provider, fixed clock. +const ALL_STATES = loadTransferFixtures(V1).map(({ name, payload }, index) => ({ + name, + payload: { + ...payload, + id: `tx_state_${index}`, + createdAt: `2026-08-0${index + 1}T10:00:00Z`, + }, +})); + +function seed(transfers) { + localStorage.setItem('remitflow.transfers', JSON.stringify(transfers)); +} + +async function gotoTransfers() { + window.history.pushState({}, '', '/transfers'); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); +} + +describe('Transfers page — every contract state renders', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-08-15T12:00:00Z')); + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders a badge for each lifecycle state, including legacy spellings', async () => { + // PAGE_SIZE is 5, so assert against the first page and then the second. + seed(ALL_STATES.map(({ payload }) => payload)); + await gotoTransfers(); + await screen.findByLabelText(/select all transfers on this page/i); + + const expectedLabels = ALL_STATES.map(({ payload }) => payload.status); + const rendered = new Set(); + + for (const page of [1, 2]) { + if (page === 2) { + await userEvent + .setup({ advanceTimers: vi.advanceTimersByTime }) + .click(screen.getByRole('button', { name: /next/i })); + } + for (const label of Object.values(TRANSFER_STATUS_LABELS)) { + if (screen.queryAllByText(label).length > 0) rendered.add(label); + } + } + + // Newest-first ordering puts the later fixtures on page 1; between the two + // pages every declared state must have appeared. + expect(expectedLabels.length).toBe(ALL_STATES.length); + for (const status of TRANSFER_STATUSES) { + expect(rendered).toContain(TRANSFER_STATUS_LABELS[status]); + } + }); + + it('offers every contract state in the status filter', async () => { + seed(ALL_STATES.map(({ payload }) => payload)); + await gotoTransfers(); + + const select = await screen.findByLabelText(/filter by status/i); + const values = within(select) + .getAllByRole('option') + .map((option) => option.value); + expect(values).toEqual(['', ...TRANSFER_STATUSES]); + }); + + it('filters by a state that did not exist before this contract', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + seed([ + { ...loadFixture(V1, 'transfer.expired'), recipient: 'gone@example.com' }, + { ...loadFixture(V1, 'transfer.pending'), recipient: 'live@example.com' }, + ]); + await gotoTransfers(); + await screen.findByText(/gone@exam/); + + await user.selectOptions( + screen.getByLabelText(/filter by status/i), + 'expired', + ); + + await waitFor(() => { + expect(window.location.search).toContain('status=expired'); + expect(screen.getByText(/gone@exam/)).toBeInTheDocument(); + expect(screen.queryByText(/live@exam/)).not.toBeInTheDocument(); + }); + }); + + it('matches a legacy status in a bookmarked URL against the canonical one', async () => { + seed([ + { ...loadFixture(V1, 'transfer.settled'), recipient: 'done@example.com' }, + { ...loadFixture(V1, 'transfer.pending'), recipient: 'live@example.com' }, + ]); + // ?status=settled is the provider spelling; the row stores `completed`. + window.history.pushState({}, '', '/transfers?status=settled'); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + + await waitFor(() => { + expect(screen.getByText(/done@exam/)).toBeInTheDocument(); + }); + expect(screen.queryByText(/live@exam/)).not.toBeInTheDocument(); + }); +}); + +describe('Transfers page — contract error states', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-08-15T12:00:00Z')); + localStorage.clear(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('drops a single corrupt record and still shows the rest', async () => { + seed([ + loadFixture(V1, 'transfer.null-amount', { kind: 'breaking' }), + { ...loadFixture(V1, 'transfer.pending'), recipient: 'live@example.com' }, + ]); + await gotoTransfers(); + + await waitFor(() => { + expect(screen.getByText(/live@exam/)).toBeInTheDocument(); + }); + // The dropped row is logged with the actionable diff, never rendered as $0.00. + expect(screen.queryByText('$0.00')).not.toBeInTheDocument(); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('sendAmount'), + ); + }); + + it('reports a whole-response schema change instead of an empty list', async () => { + seed([ + loadFixture(V1, 'transfer.renamed-amount', { kind: 'breaking' }), + { + ...loadFixture(V1, 'transfer.renamed-amount', { kind: 'breaking' }), + id: 'tx_3002', + }, + ]); + await gotoTransfers(); + + // "No transfers yet" here would be a lie — the transfers exist. + expect( + await screen.findByText(/did not match the expected format/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/no transfers yet/i)).not.toBeInTheDocument(); + + const [diff] = console.error.mock.calls.at(-1); + expect(diff).toContain('send_amount'); + expect(diff).toContain('looks like a renamed "sendAmount"'); + }); +}); diff --git a/test/unit/StatusBadge.test.jsx b/test/unit/StatusBadge.test.jsx new file mode 100644 index 0000000..7c72833 --- /dev/null +++ b/test/unit/StatusBadge.test.jsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import StatusBadge, { + TRANSFER_STATUS_LABELS, +} from '../../src/components/StatusBadge.jsx'; +import { + TRANSFER_STATUSES, + TRANSFER_STATUS_ALIASES, +} from '../../src/services/contracts/transfer.js'; + +describe('StatusBadge', () => { + it('has a label for every status the contract declares, and no extras', () => { + // Guards the drift that let `settled` render as an unstyled raw string. + expect(Object.keys(TRANSFER_STATUS_LABELS).sort()).toEqual( + [...TRANSFER_STATUSES].sort(), + ); + }); + + it.each(TRANSFER_STATUSES)('renders the %s state', (status) => { + render(); + const badge = screen.getByText(TRANSFER_STATUS_LABELS[status]); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveClass('status-badge', `status-${status}`); + // Every known state carries an explanation, not just a colour. + expect(badge).toHaveAttribute('title'); + }); + + it.each(Object.entries(TRANSFER_STATUS_ALIASES))( + 'renders the %s alias as its canonical state', + (alias, canonical) => { + render(); + const badge = screen.getByText(TRANSFER_STATUS_LABELS[canonical]); + expect(badge).toHaveClass(`status-${canonical}`); + }, + ); + + it('renders an unrecognised status visibly instead of as a blank pill', () => { + render(); + const badge = screen.getByText('in_flight'); + expect(badge).toHaveClass('status-unknown'); + expect(badge).not.toHaveAttribute('title'); + }); + + it('does not render an empty badge when the status is missing', () => { + render(); + expect(screen.getByText('Unknown')).toHaveClass('status-unknown'); + }); +}); diff --git a/test/unit/contract-schema.test.js b/test/unit/contract-schema.test.js new file mode 100644 index 0000000..29b1c81 --- /dev/null +++ b/test/unit/contract-schema.test.js @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest'; +import { + ContractViolationError, + defineContract, + formatIssues, + parseOrThrow, + validate, +} from '../../src/services/contracts/schema.js'; + +const contract = defineContract({ + name: 'Sample', + version: 3, + fields: { + id: { type: 'string', required: true, minLength: 1 }, + currency: { type: 'currency', required: true }, + amount: { type: 'decimal', required: true, min: 0 }, + attempts: { type: 'integer', required: false }, + live: { type: 'boolean', required: false }, + at: { type: 'timestamp', required: true }, + state: { + type: 'enum', + required: true, + values: ['open', 'closed'], + aliases: { finished: 'closed' }, + }, + note: { type: 'string', required: false, nullable: true }, + tier: { type: 'string', required: false, default: 'standard' }, + }, +}); + +const VALID = { + id: 'sample_1', + currency: 'USD', + amount: '10.50', + at: '2026-08-01T10:00:00Z', + state: 'open', +}; + +describe('defineContract', () => { + it('labels the contract with its name and version', () => { + expect(contract.id).toBe('Sample v3'); + }); + + it('refuses a field with an unknown type at definition time', () => { + expect(() => + defineContract({ + name: 'Bad', + version: 1, + fields: { x: { type: 'quaternion' } }, + }), + ).toThrow(/unknown type "quaternion"/); + }); +}); + +describe('validate', () => { + it('accepts a conforming payload and normalises decimals to strings', () => { + const result = validate(contract, { ...VALID, amount: 10.5 }); + expect(result.ok).toBe(true); + expect(result.value.amount).toBe('10.5'); + }); + + it('applies defaults for absent optional fields', () => { + expect(validate(contract, VALID).value.tier).toBe('standard'); + }); + + it('keeps an explicit null in a nullable field', () => { + const result = validate(contract, { ...VALID, note: null }); + expect(result.ok).toBe(true); + expect(result.value.note).toBeNull(); + }); + + it('treats a null in a non-nullable required field as missing', () => { + const result = validate(contract, { ...VALID, amount: null }); + expect(result.issues).toContainEqual( + expect.objectContaining({ path: 'amount', code: 'missing_field' }), + ); + }); + + it('normalises a known alias without raising an issue', () => { + const result = validate(contract, { ...VALID, state: 'finished' }); + expect(result.ok).toBe(true); + expect(result.value.state).toBe('closed'); + }); + + it('reports an unknown enum value with the supported set', () => { + const result = validate(contract, { ...VALID, state: 'pending' }); + expect(result.ok).toBe(false); + expect(result.issues[0]).toMatchObject({ + path: 'state', + code: 'invalid_enum', + }); + expect(result.issues[0].expected).toContain('open, closed'); + }); + + it('collects every issue rather than stopping at the first', () => { + const result = validate(contract, { + id: '', + currency: 'usd', + amount: 'free', + at: 12345, + state: 'unknown', + }); + expect(result.issues.map((i) => i.path).sort()).toEqual([ + 'amount', + 'at', + 'currency', + 'id', + 'state', + ]); + }); + + it('rejects a payload that is not an object', () => { + for (const value of [null, undefined, 'x', 42, []]) { + const result = validate(contract, value); + expect(result.ok).toBe(false); + expect(result.issues[0].code).toBe('not_an_object'); + } + }); + + it('allows and preserves unknown fields (forward compatible)', () => { + const result = validate(contract, { ...VALID, brandNewField: [1, 2] }); + expect(result.ok).toBe(true); + expect(result.value.brandNewField).toEqual([1, 2]); + }); + + it('reports a snake_case rename as a rename, not a missing field', () => { + const { amount, ...rest } = VALID; + const result = validate(contract, { ...rest, Amount_: amount }); + expect(result.issues).toContainEqual( + expect.objectContaining({ path: 'amount', code: 'renamed_field' }), + ); + expect(result.issues[0].hint).toContain('renamed "amount"'); + }); + + it('enforces numeric bounds', () => { + const result = validate(contract, { ...VALID, amount: '-1' }); + expect(result.issues[0].expected).toBe('decimal >= 0'); + }); + + it('rejects an unsafe integer', () => { + const result = validate(contract, { + ...VALID, + attempts: Number.MAX_SAFE_INTEGER + 2, + }); + expect(result.issues[0]).toMatchObject({ + path: 'attempts', + code: 'wrong_type', + }); + }); + + it('returns no value when there are issues, so a caller cannot use it', () => { + expect(validate(contract, { ...VALID, state: 'nope' }).value).toBeNull(); + }); +}); + +describe('formatIssues', () => { + const issues = validate(contract, { + ...VALID, + amount: { value: '10.50' }, + state: 'nope', + }).issues; + + const message = formatIssues(contract, issues, { source: 'GET /samples' }); + + it('names the contract, the source and the issue count', () => { + expect(message).toContain('Sample v3 contract mismatch from GET /samples'); + expect(message).toContain('(2 issues)'); + }); + + it('gives one expected/received line per issue', () => { + expect(message).toContain( + 'amount: expected decimal (number or numeric string), received object {value}', + ); + expect(message).toContain('state: expected one of [open, closed]'); + }); + + it('says what to do about it', () => { + expect(message).toContain('update the adapter for Sample v3'); + expect(message).toContain('test/fixtures/v3/'); + }); + + it('uses the singular for a single issue', () => { + const single = formatIssues(contract, [issues[0]]); + expect(single).toContain('(1 issue)'); + }); +}); + +describe('parseOrThrow', () => { + it('returns the normalised value when the payload conforms', () => { + expect(parseOrThrow(contract, VALID).id).toBe('sample_1'); + }); + + it('throws a ContractViolationError carrying the structured issues', () => { + let error; + try { + parseOrThrow(contract, { ...VALID, amount: 'free' }, { source: 'api' }); + } catch (thrown) { + error = thrown; + } + expect(error).toBeInstanceOf(ContractViolationError); + expect(error.name).toBe('ContractViolationError'); + expect(error.contract).toBe('Sample v3'); + expect(error.version).toBe(3); + expect(error.source).toBe('api'); + expect(error.issues).toHaveLength(1); + expect(error.message).toBe( + formatIssues(contract, error.issues, { source: 'api' }), + ); + }); +}); diff --git a/test/unit/money.test.js b/test/unit/money.test.js new file mode 100644 index 0000000..cfc3d45 --- /dev/null +++ b/test/unit/money.test.js @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; +import { + MONEY_PLACEHOLDER, + compareDecimal, + convertMinorUnits, + currencyExponent, + divideDecimal, + formatMoney, + fromMinorUnits, + parseDecimal, + quantize, + requireDecimal, + scaleMinorUnits, + toMinorUnits, +} from '../../src/utils/money.js'; + +describe('parseDecimal — safe numeric parsing', () => { + it('accepts numbers and numeric strings', () => { + expect(parseDecimal(200)).toEqual({ ok: true, value: '200' }); + expect(parseDecimal('200.00')).toEqual({ ok: true, value: '200' }); + expect(parseDecimal(' -12.5 ')).toEqual({ ok: true, value: '-12.5' }); + expect(parseDecimal('+0.10')).toEqual({ ok: true, value: '0.1' }); + expect(parseDecimal(0)).toEqual({ ok: true, value: '0' }); + expect(parseDecimal(-0)).toEqual({ ok: true, value: '0' }); + }); + + it('expands exponent notation instead of mangling it', () => { + expect(parseDecimal('1e3').value).toBe('1000'); + expect(parseDecimal('1.5e-4').value).toBe('0.00015'); + expect(parseDecimal(1e21).value).toBe('1000000000000000000000'); + }); + + // These are the values Number() silently maps to 0 — the coercion that let a + // broken payload render as "$0.00" instead of raising anything. + it.each([ + ['null', null], + ['undefined', undefined], + ['empty string', ''], + ['whitespace', ' '], + ['empty array', []], + ['false', false], + ['true', true], + ['object', {}], + ['money object', { value: '200.00', currency: 'USD' }], + ])('refuses to coerce %s to a number', (_label, value) => { + const parsed = parseDecimal(value); + expect(parsed.ok).toBe(false); + expect(parsed.error).toMatch(/expected/); + }); + + it.each([ + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ['non-numeric string', 'not-a-number'], + ['trailing junk', '12.5abc'], + ['grouped string', '1,480.50'], + ['hex literal', '0x1f'], + ['numeric separators', '1_000'], + ['double decimal point', '1.2.3'], + ['bare decimal point', '.'], + ])('rejects %s', (_label, value) => { + expect(parseDecimal(value).ok).toBe(false); + }); + + it('reports what actually arrived, so a log line is debuggable', () => { + expect(parseDecimal({ value: 1 }).error).toContain('object {value}'); + expect(parseDecimal('oops').error).toContain('"oops"'); + expect(parseDecimal([1, 2]).error).toContain('array(2)'); + }); + + it('normalises equal values to the same string', () => { + expect(parseDecimal('200.00').value).toBe(parseDecimal(200).value); + expect(parseDecimal('0.50').value).toBe(parseDecimal('.5').value); + expect(parseDecimal('-0.0').value).toBe('0'); + }); + + it('throws with a labelled message via requireDecimal', () => { + expect(() => requireDecimal(null, 'sendAmount')).toThrow(/sendAmount:/); + expect(requireDecimal('7.5')).toBe('7.5'); + }); +}); + +describe('minor units — exact arithmetic', () => { + it('round-trips through integer minor units', () => { + expect(toMinorUnits('200.00', 2)).toBe(20000n); + expect(fromMinorUnits(20000n, 2)).toBe('200.00'); + expect(fromMinorUnits(5n, 2)).toBe('0.05'); + expect(fromMinorUnits(-5n, 2)).toBe('-0.05'); + expect(fromMinorUnits(1234n, 0)).toBe('1234'); + }); + + it('rounds half away from zero', () => { + expect(toMinorUnits('2.345', 2)).toBe(235n); + expect(toMinorUnits('2.344', 2)).toBe(234n); + expect(toMinorUnits('-2.345', 2)).toBe(-235n); + expect(toMinorUnits('2.3449', 2)).toBe(234n); + }); + + it('honours a currency minor unit that is not 1/100', () => { + expect(currencyExponent('JPY')).toBe(0); + expect(currencyExponent('KWD')).toBe(3); + expect(currencyExponent('USD')).toBe(2); + expect(currencyExponent(undefined)).toBe(2); + expect(toMinorUnits('1234.50', currencyExponent('JPY'))).toBe(1235n); + }); + + it('does not accumulate binary floating point error', () => { + // 0.1 + 0.2 !== 0.3 as floats; as minor units it is exact. + const sum = toMinorUnits('0.1', 2) + toMinorUnits('0.2', 2); + expect(fromMinorUnits(sum, 2)).toBe('0.30'); + expect(0.1 + 0.2).not.toBe(0.3); + }); + + it('keeps large amounts exact beyond Number.MAX_SAFE_INTEGER', () => { + const huge = '123456789012345678.99'; + expect(fromMinorUnits(toMinorUnits(huge, 2), 2)).toBe(huge); + // The float round trip loses the last digits. + expect(Number(huge).toFixed(2)).not.toBe(huge); + }); +}); + +describe('rate application', () => { + it('divides to an exact cross rate', () => { + expect(divideDecimal('1480.5', '0.92')).toBe('1609.239130434783'); + expect(divideDecimal('17.1', '0.79')).toBe('21.645569620253'); + expect(divideDecimal('1', '0')).toBeNull(); + }); + + it('converts between currencies of equal precision', () => { + const received = convertMinorUnits(19890n, '1480.5', 2, 2); + expect(fromMinorUnits(received, 2)).toBe('294471.45'); + }); + + it('converts into a zero-decimal currency', () => { + // 100.00 USD at 155.25 JPY/USD is 15525 yen, not 15525.00. + const received = convertMinorUnits(10000n, '155.25', 2, 0); + expect(fromMinorUnits(received, 0)).toBe('15525'); + }); + + it('converts out of a zero-decimal currency', () => { + const received = convertMinorUnits(15525n, '0.0064412', 0, 2); + expect(fromMinorUnits(received, 2)).toBe('100.00'); + }); + + it('scales by a percentage without float drift', () => { + // 0.5% of 1234.56 is 6.1728, which rounds to 6.17. + expect(fromMinorUnits(scaleMinorUnits(123456n, '0.005'), 2)).toBe('6.17'); + expect(fromMinorUnits(scaleMinorUnits(100n, '0.005'), 2)).toBe('0.01'); + }); + + it('compares decimals exactly at the boundary', () => { + expect(compareDecimal('0.30000000000000004', '0.3')).toBe(1); + expect(compareDecimal('200.00', 200)).toBe(0); + expect(compareDecimal('-1', '1')).toBe(-1); + }); + + it('quantizes to a currency minor unit', () => { + expect(quantize('10.005', 'USD')).toBe('10.01'); + expect(quantize('10.004', 'USD')).toBe('10.00'); + expect(quantize('1234.5', 'JPY')).toBe('1235'); + }); +}); + +describe('formatMoney', () => { + it('formats parseable values with the currency minor unit', () => { + expect(formatMoney('294471.45', 'NGN', 'en-US')).toBe( + 'NGN\u00a0294,471.45', + ); + expect(formatMoney('1234.50', 'USD', 'en-US')).toBe('$1,234.50'); + expect(formatMoney(200, 'USD', 'en-US')).toBe('$200.00'); + // JPY has no minor unit; two decimals would be wrong, not just ugly. + expect(formatMoney('1234.50', 'JPY', 'en-US')).toBe('¥1,235'); + }); + + it('formats large amounts without a float round trip', () => { + expect(formatMoney('123456789012345678.99', 'NGN', 'en-US')).toBe( + 'NGN\u00a0123,456,789,012,345,678.99', + ); + }); + + // The regression this whole module exists for: an unparseable amount used to + // render as a confident, wrong "$0.00". + it.each([null, undefined, '', 'not-a-number', {}, [], Number.NaN])( + 'renders a placeholder rather than a fabricated zero for %s', + (value) => { + const rendered = formatMoney(value, 'USD', 'en-US'); + expect(rendered).toBe(MONEY_PLACEHOLDER); + expect(rendered).not.toContain('0.00'); + }, + ); + + it('falls back rather than throwing on an unusable currency code', () => { + expect(formatMoney('10.00', 'NOT_A_CODE', 'en-US')).toBe(MONEY_PLACEHOLDER); + }); + + it('respects an explicit locale', () => { + expect(formatMoney('1234.50', 'EUR', 'de-DE')).toBe('1.234,50 €'); + }); + + it('allows the caller to choose its own fallback', () => { + expect(formatMoney(null, 'USD', 'en-US', { fallback: 'n/a' })).toBe('n/a'); + }); +}); diff --git a/test/unit/quote-contract.test.js b/test/unit/quote-contract.test.js new file mode 100644 index 0000000..0888289 --- /dev/null +++ b/test/unit/quote-contract.test.js @@ -0,0 +1,222 @@ +import { describe, expect, it } from 'vitest'; +import { + loadBreakingFixtures, + loadFixture, + loadQuoteFixtures, +} from '../fixtures/index.js'; +import { + QUOTE_CONTRACT_VERSION, + QUOTE_TTL_MS, + isQuoteExpired, + parseQuote, +} from '../../src/services/contracts/quote.js'; +import { ContractViolationError } from '../../src/services/contracts/schema.js'; +import { buildQuote, calculateFee } from '../../src/services/quote.js'; +import { getRate, getRateDecimal, convert } from '../../src/services/fx.js'; + +const NOW = Date.parse('2026-08-01T10:00:00Z'); + +describe('parseQuote — v1 fixtures', () => { + const fixtures = loadQuoteFixtures(QUOTE_CONTRACT_VERSION); + + it('records at least one quote fixture', () => { + expect(fixtures.length).toBeGreaterThan(0); + }); + + it.each(fixtures.map(({ name }) => name))('%s parses', (name) => { + const quote = parseQuote(loadFixture(QUOTE_CONTRACT_VERSION, name)); + expect(quote.version).toBe(QUOTE_CONTRACT_VERSION); + }); + + const EXPECTED_BREAKING = { + 'quote.missing-expiry': { path: 'expiresAt', code: 'missing_field' }, + 'quote.formatted-rate': { path: 'rate', code: 'wrong_type' }, + }; + + it('has an expectation recorded for every breaking quote fixture', () => { + const names = loadBreakingFixtures(QUOTE_CONTRACT_VERSION) + .map(({ name }) => name) + .filter((name) => name.startsWith('quote.')); + expect(Object.keys(EXPECTED_BREAKING).sort()).toEqual(names.sort()); + }); + + it.each(Object.entries(EXPECTED_BREAKING))( + '%s is rejected with an actionable diff', + (name, expected) => { + const payload = loadFixture(QUOTE_CONTRACT_VERSION, name, { + kind: 'breaking', + }); + let error; + try { + parseQuote(payload); + } catch (thrown) { + error = thrown; + } + expect(error).toBeInstanceOf(ContractViolationError); + expect(error.issues).toContainEqual( + expect.objectContaining({ path: expected.path, code: expected.code }), + ); + expect(error.message).toContain('Quote v1'); + expect(error.message).toContain(expected.path); + }, + ); + + it('rejects a display-formatted rate rather than reading it as 1', () => { + // Number('1,480.50') is NaN and parseFloat('1,480.50') is 1 — both would + // have produced a receipt that is wrong by three orders of magnitude. + const payload = loadFixture( + QUOTE_CONTRACT_VERSION, + 'quote.formatted-rate', + { + kind: 'breaking', + }, + ); + expect(() => parseQuote(payload)).toThrow(/rate/); + expect(Number.parseFloat(payload.rate)).toBe(1); + }); +}); + +describe('isQuoteExpired', () => { + const quote = loadFixture(QUOTE_CONTRACT_VERSION, 'quote'); + + it('is not expired before its expiry', () => { + expect(isQuoteExpired(quote, Date.parse('2026-08-01T10:00:59Z'))).toBe( + false, + ); + }); + + it('is expired at and after its expiry', () => { + expect(isQuoteExpired(quote, Date.parse('2026-08-01T10:01:00Z'))).toBe( + true, + ); + expect(isQuoteExpired(quote, Date.parse('2026-08-01T10:05:00Z'))).toBe( + true, + ); + }); + + it('accepts a Date as well as a timestamp', () => { + expect(isQuoteExpired(quote, new Date('2026-08-01T10:05:00Z'))).toBe(true); + }); + + it('treats an unreadable expiry as expired rather than valid', () => { + expect(isQuoteExpired({ expiresAt: 'not-a-date' }, NOW)).toBe(true); + }); +}); + +describe('fx rates', () => { + it('derives an exact cross rate', () => { + // 1480.5 / 0.92 in binary floating point is 1609.2391304347825. + expect(getRateDecimal('EUR', 'NGN')).toBe('1609.239130434783'); + expect(getRateDecimal('USD', 'NGN')).toBe('1480.5'); + expect(getRateDecimal('NGN', 'NGN')).toBe('1'); + }); + + it('returns null for an unsupported pair instead of NaN', () => { + expect(getRateDecimal('USD', 'XXX')).toBeNull(); + expect(getRate('XXX', 'USD')).toBeNull(); + expect(convert(100, 'USD', 'XXX')).toBeNull(); + }); + + it('refuses to convert an unparseable amount instead of returning 0', () => { + expect(convert(null, 'USD', 'NGN')).toBeNull(); + expect(convert('', 'USD', 'NGN')).toBeNull(); + expect(convert('abc', 'USD', 'NGN')).toBeNull(); + }); +}); + +describe('calculateFee', () => { + it('charges the percentage plus the flat fee', () => { + // 0.5% of 200.00 is 1.00, plus a 0.10 flat fee. + expect(calculateFee('200', 'USD')).toBe('1.10'); + expect(calculateFee('1234.56', 'USD')).toBe('6.27'); + }); + + it('applies the minimum fee to small transfers', () => { + expect(calculateFee('10', 'USD')).toBe('0.25'); + expect(calculateFee('0', 'USD')).toBe('0.25'); + }); + + it('uses the destination currency minor unit', () => { + expect(calculateFee('10000', 'JPY')).toBe('50'); + }); + + it('returns null rather than a fee of 0.25 on garbage input', () => { + expect(calculateFee(null)).toBeNull(); + expect(calculateFee('abc')).toBeNull(); + expect(calculateFee('')).toBeNull(); + }); +}); + +describe('buildQuote', () => { + it('produces a contract-valid quote', () => { + const quote = buildQuote('200', 'USD', 'NGN', { now: NOW }); + expect(() => parseQuote(quote)).not.toThrow(); + expect(quote).toMatchObject({ + version: QUOTE_CONTRACT_VERSION, + from: 'USD', + to: 'NGN', + rate: '1480.5', + sendAmount: '200', + fee: '1.1', + amountAfterFee: '198.9', + receiveAmount: '294471.45', + createdAt: '2026-08-01T10:00:00.000Z', + expiresAt: '2026-08-01T10:01:00.000Z', + }); + }); + + it('matches an independently computed cross-currency quote', () => { + // 100 EUR - 0.60 fee = 99.40, at 1609.239130434783 NGN/EUR = 159958.37. + expect(buildQuote('100', 'EUR', 'NGN', { now: NOW })).toMatchObject({ + fee: '0.6', + amountAfterFee: '99.4', + receiveAmount: '159958.37', + }); + }); + + it('is deterministic for the same inputs', () => { + expect(buildQuote('137.77', 'GBP', 'MXN', { now: NOW })).toEqual( + buildQuote('137.77', 'GBP', 'MXN', { now: NOW }), + ); + }); + + it('accepts numbers and equivalent strings interchangeably', () => { + expect(buildQuote(200, 'USD', 'NGN', { now: NOW })).toEqual( + buildQuote('200.00', 'USD', 'NGN', { now: NOW }), + ); + }); + + it('never lets the fee push the receive amount negative', () => { + const quote = buildQuote('0.05', 'USD', 'NGN', { now: NOW }); + expect(quote.amountAfterFee).toBe('0'); + expect(quote.receiveAmount).toBe('0'); + }); + + it('rounds to the destination currency minor unit', () => { + const quote = buildQuote('100', 'USD', 'INR', { now: NOW }); + // 100.00 - 0.60 fee = 99.40, and 99.40 * 83.2 = 8270.08 exactly. + expect(quote.receiveAmount).toBe('8270.08'); + expect(quote.receiveAmount).not.toMatch(/0000\d$/); + }); + + it('expires TTL milliseconds after it is built', () => { + const quote = buildQuote('200', 'USD', 'NGN', { now: NOW }); + expect(Date.parse(quote.expiresAt) - Date.parse(quote.createdAt)).toBe( + QUOTE_TTL_MS, + ); + expect(isQuoteExpired(quote, NOW + QUOTE_TTL_MS)).toBe(true); + }); + + it('returns null for an unsupported pair', () => { + expect(buildQuote('200', 'USD', 'XXX', { now: NOW })).toBeNull(); + }); + + // Regression: the old implementation ran `Number(amount) || 0` and quoted a + // real transfer of 0.00 for any unparseable amount. + it.each([null, undefined, '', 'abc', {}, [], Number.NaN])( + 'returns null rather than a zero quote for %s', + (amount) => { + expect(buildQuote(amount, 'USD', 'NGN', { now: NOW })).toBeNull(); + }, + ); +}); diff --git a/test/unit/receipt-rendering.test.jsx b/test/unit/receipt-rendering.test.jsx new file mode 100644 index 0000000..5145401 --- /dev/null +++ b/test/unit/receipt-rendering.test.jsx @@ -0,0 +1,103 @@ +import { render, screen, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import TransferRow from '../../src/components/TransferRow.jsx'; +import QuoteCard from '../../src/components/QuoteCard.jsx'; +import { MONEY_PLACEHOLDER } from '../../src/utils/money.js'; +import { parseTransfer } from '../../src/services/contracts/transfer.js'; +import { loadFixture } from '../fixtures/index.js'; + +const V1 = 1; + +// Intl separates a currency code from its amount with a non-breaking space. +// Compare on the collapsed form so the expectations stay readable whatever +// Testing Library's normalizer does with it. +const money = (expected) => (content) => + content.replace(/\u00a0/g, ' ') === expected; + +function renderRow(overrides = {}) { + const transfer = { + ...parseTransfer(loadFixture(V1, 'transfer.pending')), + ...overrides, + }; + return render(); +} + +describe('TransferRow — receipt amounts', () => { + it('renders contract-normalised decimal strings', () => { + renderRow(); + expect(screen.getByText('$120.00')).toBeInTheDocument(); + expect(screen.getByText('₹9,925.76')).toBeInTheDocument(); + }); + + it('renders the same value whether the wire sent a number or a string', () => { + const { unmount } = renderRow({ sendAmount: '120' }); + expect(screen.getByText('$120.00')).toBeInTheDocument(); + unmount(); + + renderRow({ sendAmount: 120 }); + expect(screen.getByText('$120.00')).toBeInTheDocument(); + }); + + // The failure this change exists to prevent: an amount that failed to parse + // used to be rendered as a confident "$0.00". + it.each([ + ['null', null], + ['undefined', undefined], + ['empty string', ''], + ['a money object', { value: '120.00', currency: 'USD' }], + ['a formatted string', '1,20.00'], + ])('shows a placeholder, not $0.00, when sendAmount is %s', (_l, value) => { + renderRow({ sendAmount: value }); + expect(screen.getByText(MONEY_PLACEHOLDER)).toBeInTheDocument(); + expect(screen.queryByText('$0.00')).not.toBeInTheDocument(); + }); + + it('keeps the destination amount when only the source amount is broken', () => { + renderRow({ sendAmount: null }); + expect(screen.getByText('₹9,925.76')).toBeInTheDocument(); + }); + + it('renders a genuine zero as a zero', () => { + renderRow({ sendAmount: '0' }); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + }); + + it('renders a large destination amount without float rounding', () => { + renderRow({ to: 'NGN', receiveAmount: '123456789012345678.99' }); + expect( + screen.getByText(money('NGN 123,456,789,012,345,678.99')), + ).toBeInTheDocument(); + }); +}); + +describe('QuoteCard', () => { + const quote = loadFixture(V1, 'quote'); + + it('renders the quoted amounts and rate exactly as quoted', () => { + render(); + expect(screen.getByText('$200.00')).toBeInTheDocument(); + expect(screen.getByText('- $1.10')).toBeInTheDocument(); + expect(screen.getByText(money('NGN 294,471.45'))).toBeInTheDocument(); + expect(screen.getByText('1 USD = 1480.5000 NGN')).toBeInTheDocument(); + }); + + it('renders a high-precision cross rate without truncating to an integer', () => { + render( + , + ); + // parseFloat('1,609.24') would have read this as 1. + expect(screen.getByText('1 EUR = 1609.2391 NGN')).toBeInTheDocument(); + expect(screen.getByText(money('NGN 159,958.37'))).toBeInTheDocument(); + }); + + it('renders nothing at all when there is no quote', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows a placeholder rate rather than a wrong one', () => { + render(); + const line = screen.getByText('Exchange rate').closest('.quote-line'); + expect(within(line).getByText('-')).toBeInTheDocument(); + }); +}); diff --git a/test/unit/transfer-contract.test.js b/test/unit/transfer-contract.test.js new file mode 100644 index 0000000..ad13366 --- /dev/null +++ b/test/unit/transfer-contract.test.js @@ -0,0 +1,267 @@ +import { describe, expect, it } from 'vitest'; +import { + SUPPORTED_FIXTURE_VERSIONS, + loadBreakingFixtures, + loadFixture, + loadTransferFixtures, +} from '../fixtures/index.js'; +import { + TRANSFER_CONTRACT_VERSION, + TRANSFER_STATUSES, + TRANSFER_STATUS_ALIASES, + isTerminalStatus, + normalizeStatus, + parseTransfer, + parseTransferList, + transferContract, +} from '../../src/services/contracts/transfer.js'; +import { ContractViolationError } from '../../src/services/contracts/schema.js'; +import { parseDecimal } from '../../src/utils/money.js'; + +describe('fixture set', () => { + it('records a fixture set for the current contract version', () => { + expect(SUPPORTED_FIXTURE_VERSIONS).toContain(TRANSFER_CONTRACT_VERSION); + }); + + it('covers every lifecycle state the contract declares', () => { + const covered = new Set( + loadTransferFixtures(TRANSFER_CONTRACT_VERSION).map(({ payload }) => + normalizeStatus(payload.status), + ), + ); + // Fails the moment a state is added to the contract without a fixture. + expect([...covered].sort()).toEqual([...TRANSFER_STATUSES].sort()); + }); +}); + +describe('parseTransfer — v1 fixtures', () => { + const fixtures = loadTransferFixtures(TRANSFER_CONTRACT_VERSION); + + it.each(fixtures.map(({ name }) => name))('%s parses', (name) => { + expect(() => + parseTransfer(loadFixture(TRANSFER_CONTRACT_VERSION, name)), + ).not.toThrow(); + }); + + it.each(fixtures.map(({ name }) => name))( + '%s yields amounts that are exact decimals, never NaN', + (name) => { + const transfer = parseTransfer( + loadFixture(TRANSFER_CONTRACT_VERSION, name), + ); + for (const field of ['sendAmount', 'receiveAmount']) { + expect(parseDecimal(transfer[field]).ok).toBe(true); + expect(Number.isNaN(Number(transfer[field]))).toBe(false); + } + }, + ); + + it('normalises numeric and string amounts to the same value', () => { + const legacy = parseTransfer( + loadFixture(TRANSFER_CONTRACT_VERSION, 'transfer.completed-legacy'), + ); + // The legacy fixture carries JSON numbers; the modern ones carry strings. + expect(legacy.sendAmount).toBe('200'); + expect(legacy.receiveAmount).toBe('294620'); + }); + + it('normalises the "settled" wire spelling to the canonical status', () => { + const settled = parseTransfer( + loadFixture(TRANSFER_CONTRACT_VERSION, 'transfer.settled'), + ); + expect(settled.status).toBe('completed'); + }); + + it('preserves fields a newer provider has added', () => { + const forward = parseTransfer( + loadFixture(TRANSFER_CONTRACT_VERSION, 'transfer.forward-compatible'), + ); + // Additive changes must not break a released client. + expect(forward.settlementNetwork).toBe('stellar'); + expect(forward.status).toBe('pending'); + }); + + it('keeps an explicit null in a nullable field', () => { + const expired = parseTransfer( + loadFixture(TRANSFER_CONTRACT_VERSION, 'transfer.expired'), + ); + expect(expired.failureReason).toBeNull(); + expect(expired.expiresAt).toBe('2026-08-01T12:01:00Z'); + }); +}); + +describe('breaking response changes', () => { + const breaking = loadBreakingFixtures(TRANSFER_CONTRACT_VERSION).filter( + ({ name }) => name.startsWith('transfer.'), + ); + + it.each(breaking.map(({ name }) => name))('%s is rejected', (name) => { + const payload = loadFixture(TRANSFER_CONTRACT_VERSION, name, { + kind: 'breaking', + }); + expect(() => parseTransfer(payload)).toThrow(ContractViolationError); + }); + + // The point of the contract is not that it fails, but that the failure says + // what to do. Each entry pins the field and the code the diff must name. + const EXPECTED = { + 'transfer.renamed-amount': { path: 'sendAmount', code: 'renamed_field' }, + 'transfer.money-object-amount': { + path: 'sendAmount', + code: 'wrong_type', + }, + 'transfer.unknown-status': { path: 'status', code: 'invalid_enum' }, + 'transfer.null-amount': { path: 'sendAmount', code: 'missing_field' }, + 'transfer.epoch-timestamp': { path: 'createdAt', code: 'wrong_type' }, + 'transfer.non-iso-currency': { path: 'from', code: 'wrong_type' }, + }; + + it('has an expectation recorded for every breaking fixture', () => { + expect(Object.keys(EXPECTED).sort()).toEqual( + breaking.map(({ name }) => name).sort(), + ); + }); + + it.each(Object.entries(EXPECTED))( + '%s names the offending field in the diff', + (name, expected) => { + const payload = loadFixture(TRANSFER_CONTRACT_VERSION, name, { + kind: 'breaking', + }); + let error; + try { + parseTransfer(payload, { source: 'fixture' }); + } catch (thrown) { + error = thrown; + } + + expect(error).toBeInstanceOf(ContractViolationError); + expect(error.contract).toBe(transferContract.id); + expect(error.issues).toContainEqual( + expect.objectContaining({ path: expected.path, code: expected.code }), + ); + + // Actionable: the message names the contract, the field, what was + // expected, what arrived, and where to fix it. + expect(error.message).toContain('Transfer v1'); + expect(error.message).toContain(expected.path); + expect(error.message).toMatch(/expected .+, received .+/); + expect(error.message).toContain('test/fixtures/v1/'); + }, + ); + + it('suggests the rename rather than reporting an unrelated missing field', () => { + const payload = loadFixture( + TRANSFER_CONTRACT_VERSION, + 'transfer.renamed-amount', + { kind: 'breaking' }, + ); + let message = ''; + try { + parseTransfer(payload); + } catch (error) { + message = error.message; + } + expect(message).toContain('send_amount'); + expect(message).toContain('looks like a renamed "sendAmount"'); + }); + + it('lists an unknown status alongside the statuses it does support', () => { + const payload = loadFixture( + TRANSFER_CONTRACT_VERSION, + 'transfer.unknown-status', + { kind: 'breaking' }, + ); + let message = ''; + try { + parseTransfer(payload); + } catch (error) { + message = error.message; + } + expect(message).toContain('in_flight'); + for (const status of TRANSFER_STATUSES) { + expect(message).toContain(status); + } + }); +}); + +describe('parseTransferList', () => { + const valid = (status, id) => ({ + ...loadFixture(TRANSFER_CONTRACT_VERSION, 'transfer.pending'), + id, + status, + }); + + it('keeps the good rows when one row is corrupt', () => { + const corrupt = loadFixture( + TRANSFER_CONTRACT_VERSION, + 'transfer.null-amount', + { kind: 'breaking' }, + ); + const result = parseTransferList( + [valid('pending', 'a'), corrupt, valid('completed', 'b')], + { source: 'listTransfers' }, + ); + + expect(result.transfers.map((t) => t.id)).toEqual(['a', 'b']); + expect(result.rejected).toHaveLength(1); + expect(result.rejected[0].index).toBe(1); + expect(result.rejected[0].diff).toContain('listTransfers[1]'); + expect(result.breaking).toBe(false); + }); + + it('flags a whole-response schema change rather than an empty list', () => { + const corrupt = loadFixture( + TRANSFER_CONTRACT_VERSION, + 'transfer.renamed-amount', + { kind: 'breaking' }, + ); + const result = parseTransferList([corrupt, { ...corrupt, id: 'tx_2' }]); + expect(result.transfers).toEqual([]); + expect(result.breaking).toBe(true); + }); + + it('does not call a genuinely empty response a schema change', () => { + expect(parseTransferList([])).toMatchObject({ + transfers: [], + breaking: false, + }); + }); + + it('rejects a response that is not a list at all', () => { + expect(() => parseTransferList({ data: [] })).toThrow( + ContractViolationError, + ); + expect(() => parseTransferList(null)).toThrow(/expected array of Transfer/); + }); +}); + +describe('status helpers', () => { + it('passes canonical statuses through unchanged', () => { + for (const status of TRANSFER_STATUSES) { + expect(normalizeStatus(status)).toBe(status); + } + }); + + it('maps every declared alias onto a canonical status', () => { + for (const [alias, canonical] of Object.entries(TRANSFER_STATUS_ALIASES)) { + expect(TRANSFER_STATUSES).toContain(canonical); + expect(normalizeStatus(alias)).toBe(canonical); + } + }); + + it('returns null for a status it does not recognise', () => { + expect(normalizeStatus('in_flight')).toBeNull(); + expect(normalizeStatus(undefined)).toBeNull(); + expect(normalizeStatus(42)).toBeNull(); + }); + + it('identifies terminal states', () => { + expect(isTerminalStatus('completed')).toBe(true); + expect(isTerminalStatus('settled')).toBe(true); + expect(isTerminalStatus('failed')).toBe(true); + expect(isTerminalStatus('expired')).toBe(true); + expect(isTerminalStatus('pending')).toBe(false); + expect(isTerminalStatus('quoted')).toBe(false); + }); +}); From e0a91971d8def0e5a491c4266f1de6bc18caf8e0 Mon Sep 17 00:00:00 2001 From: ayom04 Date: Sun, 23 Aug 2026 16:49:36 +0100 Subject: [PATCH 2/2] fix: improve error reporting for contract violations in transfer listing --- src/services/api.js | 5 ++++- test/integration/transfer-states.test.jsx | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/api.js b/src/services/api.js index 5a377d5..d8d2797 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -83,14 +83,17 @@ export function listTransfers() { const { transfers, rejected, breaking } = parseTransferList(read(), { source: 'listTransfers', }); - if (rejected.length) reportRejected(rejected); if (breaking) { + // Every row failed: raise one aggregate error carrying all the + // issues. Reporting each row here as well would log the same diff + // twice, since the caller logs whatever it catches. throw new ContractViolationError( transferContract, rejected.flatMap((entry) => entry.issues), { source: 'listTransfers' }, ); } + if (rejected.length) reportRejected(rejected); resolve( transfers .slice() diff --git a/test/integration/transfer-states.test.jsx b/test/integration/transfer-states.test.jsx index 58812f0..fa50579 100644 --- a/test/integration/transfer-states.test.jsx +++ b/test/integration/transfer-states.test.jsx @@ -167,6 +167,8 @@ describe('Transfers page — contract error states', () => { ).toBeInTheDocument(); expect(screen.queryByText(/no transfers yet/i)).not.toBeInTheDocument(); + // One aggregate diff, not one per rejected row plus the aggregate. + expect(console.error).toHaveBeenCalledTimes(1); const [diff] = console.error.mock.calls.at(-1); expect(diff).toContain('send_amount'); expect(diff).toContain('looks like a renamed "sendAmount"');