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 }) {
@@ -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 (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()
+ .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..fa50579
--- /dev/null
+++ b/test/integration/transfer-states.test.jsx
@@ -0,0 +1,176 @@
+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();
+
+ // 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"');
+ });
+});
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);
+ });
+});