diff --git a/src/components/QuoteCard.css b/src/components/QuoteCard.css
index 4b27d23..c3f8324 100644
--- a/src/components/QuoteCard.css
+++ b/src/components/QuoteCard.css
@@ -3,6 +3,12 @@
border: 1px solid #1f2a3a;
border-radius: 12px;
padding: 1.25rem 1.5rem;
+ transition: border-color 0.2s;
+}
+
+.quote-card--expired {
+ border-color: var(--color-error, #e53935);
+ opacity: 0.75;
}
.quote-title {
@@ -10,6 +16,37 @@
font-size: 1rem;
}
+/* Currency corridor: "USD (πΊπΈ US Dollar) β NGN (π³π¬ Nigerian Naira)" */
+.quote-corridor {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin-bottom: 1rem;
+ font-size: 0.85rem;
+ color: var(--color-muted);
+}
+
+.quote-currency {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.quote-flag {
+ font-size: 1.1em;
+}
+
+.quote-code {
+ color: var(--color-muted);
+ font-size: 0.8em;
+}
+
+.quote-corridor-arrow {
+ font-size: 0.9rem;
+}
+
+/* Main quote lines */
.quote-line {
display: flex;
justify-content: space-between;
@@ -32,13 +69,66 @@
font-size: 1.1rem;
}
+/* Metadata row: source Β· timestamp Β· expiry */
+.quote-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+ font-size: 0.75rem;
+ color: var(--color-muted);
+}
+
+.quote-source {
+ font-style: italic;
+}
+
+.quote-timestamp {
+ /* no extra style needed */
+}
+
+.quote-expiry {
+ margin-left: auto;
+ font-weight: 500;
+}
+
+.quote-expiry--soon {
+ color: var(--color-warning, #f59e0b);
+ font-weight: 600;
+}
+
+.quote-expiry--expired {
+ color: var(--color-error, #e53935);
+ font-weight: 700;
+}
+
+/* Footer note */
.quote-note {
- margin: 1rem 0 0;
+ margin: 0.75rem 0 0;
font-size: 0.8rem;
line-height: 1.4;
color: var(--color-muted);
}
+/* Quote expired / warning banners (rendered in SendMoney, not QuoteCard) */
+.send-quote-expired {
+ font-size: 0.875rem;
+ color: var(--color-error, #e53935);
+ padding: 0.5rem 0.75rem;
+ background: color-mix(in srgb, var(--color-error, #e53935) 10%, transparent);
+ border-radius: 6px;
+ margin-bottom: 0.75rem;
+}
+
+.send-quote-warning {
+ font-size: 0.875rem;
+ color: var(--color-warning, #f59e0b);
+ padding: 0.5rem 0.75rem;
+ background: color-mix(in srgb, var(--color-warning, #f59e0b) 10%, transparent);
+ border-radius: 6px;
+ margin-bottom: 0.75rem;
+}
+
@media (prefers-contrast: more) {
.quote-card {
border-width: 2px;
@@ -47,4 +137,12 @@
.quote-divider {
height: 2px;
}
+
+ .quote-expiry--soon {
+ text-decoration: underline;
+ }
+
+ .quote-expiry--expired {
+ text-decoration: underline;
+ }
}
diff --git a/src/components/QuoteCard.jsx b/src/components/QuoteCard.jsx
index a16d5e5..6e7190c 100644
--- a/src/components/QuoteCard.jsx
+++ b/src/components/QuoteCard.jsx
@@ -5,19 +5,82 @@ import './QuoteCard.css';
/**
* Displays the breakdown of an FX quote: rate, fee and amount received.
+ *
+ * In addition to the core send/fee/receive fields, it shows:
+ * - The currency flags and full names for both source and destination
+ * - The quote source (provider tag)
+ * - A human-readable timestamp ("obtained at HH:MM:SS")
+ * - The remaining time until the quote expires
+ *
* @param {object} props
- * @param {object} props.quote - quote object from buildQuote()
+ * @param {object} props.quote - enhanced quote object from buildQuote()
* @param {string} [props.locale] - locale used for currency formatting
+ * @param {number|null} [props.secsLeft] - seconds until the quote expires
*/
-export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {
+export default function QuoteCard({ quote, locale = DEFAULT_LOCALE, secsLeft = null }) {
if (!quote) return null;
- const { from, to, rate, sendAmount, fee, receiveAmount } = quote;
+ const {
+ from,
+ to,
+ fromMeta,
+ toMeta,
+ rate,
+ sendAmount,
+ fee,
+ receiveAmount,
+ source,
+ timestamp,
+ } = quote;
+
+ // Format the timestamp for display (e.g. "14:03:27").
+ const obtainedAt = timestamp
+ ? new Date(timestamp).toLocaleTimeString(locale, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ })
+ : null;
+
+ // Determine expiry display state.
+ const isExpired = secsLeft !== null && secsLeft <= 0;
+ const isExpiringSoon = !isExpired && secsLeft !== null && secsLeft <= 10;
+
+ // Build a descriptive expiry label.
+ let expiryLabel = null;
+ if (isExpired) {
+ expiryLabel = 'Expired';
+ } else if (secsLeft !== null) {
+ expiryLabel = `${secsLeft}s`;
+ }
return (
-
+
Transfer summary
+ {/* Currency corridor header */}
+
+
+ {fromMeta?.flag && (
+
+ {fromMeta.flag}
+
+ )}
+ {fromMeta?.name ?? from}
+ ({from})
+
+ β
+
+ {toMeta?.flag && (
+
+ {toMeta.flag}
+
+ )}
+ {toMeta?.name ?? to}
+ ({to})
+
+
+
You send
{formatAmount(sendAmount, from, locale)}
@@ -40,9 +103,33 @@ export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {
{formatAmount(receiveAmount, to, locale)}
+ {/* Quote metadata footer */}
+
+ {source && (
+
+ {source}
+
+ )}
+ {obtainedAt && (
+
+ obtained {obtainedAt}
+
+ )}
+ {expiryLabel && (
+
+ {isExpired ? 'Quote expired' : `expires in ${expiryLabel}`}
+
+ )}
+
+
Fees cover the RemitFlow service and Stellar network cost. Rates are
- indicative and update at confirmation.
+ indicative and locked for the duration shown above.
);
diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx
index 344b26c..56ee032 100644
--- a/src/pages/SendMoney.jsx
+++ b/src/pages/SendMoney.jsx
@@ -1,11 +1,16 @@
-import { useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import TextField from '../components/TextField.jsx';
import CurrencySelect from '../components/CurrencySelect.jsx';
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 {
+ buildQuote,
+ isQuoteExpired,
+ isQuoteStale,
+ QUOTE_TTL_MS,
+} from '../services/quote.js';
import { formatCurrencyInput } from '../utils/format.js';
import {
isPositiveAmount,
@@ -19,8 +24,19 @@ import { useDebouncedValue } from '../hooks/useDebouncedValue.js';
import { DEFAULT_SOURCE, DEFAULT_DEST } from '../constants/currencies.js';
import './SendMoney.css';
+// How many ms before expiry to start showing the "expiring soon" warning.
+const EXPIRY_WARN_MS = 10_000;
+
/**
* Send Money page: recipient + amount form with a live FX quote.
+ *
+ * Quote lifecycle:
+ * 1. Quote is generated (via useDebouncedValue) whenever amount/from/to change.
+ * 2. Quote is cleared immediately when currency or amount fields change, so
+ * the stale rate is never visible after user edits.
+ * 3. On submit, the quote is re-validated: if it has expired or is stale it is
+ * regenerated rather than silently used.
+ * 4. The quoteId from the final quote is bound into the transfer payload.
*/
export default function SendMoney() {
const navigate = useNavigate();
@@ -35,20 +51,79 @@ export default function SendMoney() {
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
+
+ // The live FX quote displayed to the user.
+ const [quote, setQuote] = useState(null);
+
+ // Tracks seconds remaining until quote expires; drives the countdown badge.
+ const [quoteSecsLeft, setQuoteSecsLeft] = useState(null);
+
const submissionLock = useRef(false);
+ const countdownRef = useRef(null);
// Debounce the amount so the quote isn't rebuilt on every keystroke.
const debouncedAmount = useDebouncedValue(amount, 250);
- // Recompute the quote whenever the (debounced) inputs change.
- const quote = useMemo(() => {
- if (!isPositiveAmount(debouncedAmount)) return null;
- return buildQuote(debouncedAmount, from, to);
+ // --- Quote generation ---
+ // Rebuild the quote whenever the debounced inputs change.
+ useEffect(() => {
+ if (!isPositiveAmount(debouncedAmount)) {
+ setQuote(null);
+ setQuoteSecsLeft(null);
+ return;
+ }
+ const next = buildQuote(debouncedAmount, from, to);
+ setQuote(next);
+ setQuoteSecsLeft(next ? Math.ceil(QUOTE_TTL_MS / 1000) : null);
}, [debouncedAmount, from, to]);
+ // --- Quote invalidation on direct field edits ---
+ // Clear the quote as soon as the user begins editing, so the stale quote is
+ // never visible at the moment they submit. The debounced effect above will
+ // generate a fresh quote once typing settles.
+ const handleAmountChange = useCallback((value) => {
+ setAmount(value);
+ setQuote(null);
+ setQuoteSecsLeft(null);
+ }, []);
+
+ const handleFromChange = useCallback((value) => {
+ setFrom(value);
+ setQuote(null);
+ setQuoteSecsLeft(null);
+ }, []);
+
+ const handleToChange = useCallback((value) => {
+ setTo(value);
+ setQuote(null);
+ setQuoteSecsLeft(null);
+ }, []);
+
+ // --- Expiry countdown ticker ---
+ useEffect(() => {
+ clearInterval(countdownRef.current);
+ if (!quote) return;
+
+ countdownRef.current = setInterval(() => {
+ const remaining = Math.ceil((quote.expiresAt - Date.now()) / 1000);
+ if (remaining <= 0) {
+ setQuoteSecsLeft(0);
+ clearInterval(countdownRef.current);
+ } else {
+ setQuoteSecsLeft(remaining);
+ }
+ }, 1000);
+
+ return () => clearInterval(countdownRef.current);
+ }, [quote]);
+
function swapCurrencies() {
- setFrom(to);
- setTo(from);
+ const prevFrom = from;
+ const prevTo = to;
+ setFrom(prevTo);
+ setTo(prevFrom);
+ setQuote(null);
+ setQuoteSecsLeft(null);
}
// Tidy the amount field to two decimals once the user leaves it.
@@ -98,11 +173,18 @@ export default function SendMoney() {
await connect();
}
- // Build from the live amount so a pending debounce can't submit a stale quote.
- const finalQuote = buildQuote(amount, from, to);
+ // Resolve a valid, fresh quote at the moment of submission.
+ // If the current quote is expired or stale (field values changed),
+ // rebuild it from the current form values so the payload is always
+ // consistent with what the user sees.
+ let finalQuote = quote;
+ if (!finalQuote || isQuoteExpired(finalQuote) || isQuoteStale(finalQuote, from, to, amount)) {
+ finalQuote = buildQuote(amount, from, to);
+ }
if (!finalQuote) return;
await addTransfer({
+ quoteId: finalQuote.quoteId,
recipient,
from,
to,
@@ -119,6 +201,12 @@ export default function SendMoney() {
}
const errorCount = Object.keys(errors).length;
+ const quoteIsExpired = quote !== null && quoteSecsLeft !== null && quoteSecsLeft <= 0;
+ const quoteIsExpiringSoon =
+ !quoteIsExpired &&
+ quote !== null &&
+ quoteSecsLeft !== null &&
+ quoteSecsLeft * 1000 <= EXPIRY_WARN_MS;
return (
@@ -153,7 +241,7 @@ export default function SendMoney() {
label="Amount"
type="number"
value={amount}
- onChange={setAmount}
+ onChange={handleAmountChange}
onBlur={handleAmountBlur}
placeholder="0.00"
error={errors.amount}
@@ -164,7 +252,7 @@ export default function SendMoney() {
id="from"
label="From"
value={from}
- onChange={setFrom}
+ onChange={handleFromChange}
/>
@@ -192,13 +280,34 @@ export default function SendMoney() {
- {quote ? (
-
- ) : (
-
- Enter an amount to see your quote.
+ {quoteIsExpired && (
+
+ Quote expired β enter your amount to refresh.
)}
+ {!quoteIsExpired && quoteIsExpiringSoon && (
+
+ Quote expires in {quoteSecsLeft}s β confirm soon.
+
+ )}
+ {!quoteIsExpired && quote ? (
+
+ ) : (
+ !quoteIsExpired && (
+
+ Enter an amount to see your quote.
+
+ )
+ )}
diff --git a/src/services/quote.js b/src/services/quote.js
index 479497b..590c300 100644
--- a/src/services/quote.js
+++ b/src/services/quote.js
@@ -1,8 +1,30 @@
// Quote service: combines FX rates and fees into a full transfer quote.
import { getRate, convert } from './fx.js';
+import { getCurrency } from '../constants/currencies.js';
import { FEE_PERCENT, FLAT_FEE, MIN_FEE } from '../constants/fees.js';
import { percentOf, roundTo } from '../utils/math.js';
+/**
+ * How long (in milliseconds) a quote is considered valid before it must be
+ * refreshed. Chosen to be short enough that the rate is fresh at confirmation
+ * but long enough to not interrupt normal form completion.
+ */
+export const QUOTE_TTL_MS = 30_000; // 30 seconds
+
+/** Identify the source of quotes; useful for display and debugging. */
+export const QUOTE_SOURCE = 'RemitFlow/mock-v1';
+
+/**
+ * Generate a lightweight, collision-resistant quote identifier.
+ * Not a cryptographic UUID β just enough to bind a quote to a payload.
+ * @returns {string}
+ */
+export function generateQuoteId() {
+ const ts = Date.now().toString(36);
+ const rand = Math.random().toString(36).slice(2, 9);
+ return `q_${ts}_${rand}`;
+}
+
/**
* Calculate the total fee (in the source currency) for a given send amount.
* @param {number} amount - amount being sent in the source currency
@@ -16,7 +38,23 @@ export function calculateFee(amount) {
/**
* Build a full quote for a transfer.
- * @param {number} amount - amount to send in the source currency
+ *
+ * The returned object includes:
+ * - `quoteId` unique identifier for this specific quote snapshot
+ * - `source` identifies the quote provider (mock vs. live)
+ * - `timestamp` Unix epoch ms when the quote was generated
+ * - `expiresAt` Unix epoch ms when the quote expires (timestamp + QUOTE_TTL_MS)
+ * - `from` source currency code
+ * - `to` destination currency code
+ * - `fromMeta` full currency metadata for the source (name, symbol, flag)
+ * - `toMeta` full currency metadata for the destination
+ * - `rate` exchange rate: 1 unit of `from` in `to`
+ * - `sendAmount` canonical send amount (number, parsed once here)
+ * - `fee` total fee in source currency
+ * - `amountAfterFee`send amount minus fee
+ * - `receiveAmount` amount the recipient receives in destination 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
@@ -25,14 +63,25 @@ export function buildQuote(amount, from, to) {
const rate = getRate(from, to);
if (rate == null) return null;
- const sendAmount = Number(amount) || 0;
+ const fromMeta = getCurrency(from);
+ const toMeta = getCurrency(to);
+
+ const sendAmount = roundTo(Number(amount) || 0, 2);
const fee = calculateFee(sendAmount);
- const amountAfterFee = Math.max(sendAmount - fee, 0);
- const receiveAmount = convert(amountAfterFee, from, to);
+ const amountAfterFee = roundTo(Math.max(sendAmount - fee, 0), 2);
+ const receiveAmount = roundTo(convert(amountAfterFee, from, to) ?? 0, 2);
+
+ const timestamp = Date.now();
return {
+ quoteId: generateQuoteId(),
+ source: QUOTE_SOURCE,
+ timestamp,
+ expiresAt: timestamp + QUOTE_TTL_MS,
from,
to,
+ fromMeta: fromMeta ?? { code: from, name: from, symbol: from, flag: '' },
+ toMeta: toMeta ?? { code: to, name: to, symbol: to, flag: '' },
rate,
sendAmount,
fee,
@@ -40,3 +89,34 @@ export function buildQuote(amount, from, to) {
receiveAmount,
};
}
+
+/**
+ * Returns true when the given quote has passed its expiry time.
+ * @param {object|null} quote - a quote returned by buildQuote()
+ * @returns {boolean}
+ */
+export function isQuoteExpired(quote) {
+ if (!quote) return true;
+ return Date.now() >= quote.expiresAt;
+}
+
+/**
+ * Returns true when the quote's currency pair and canonical amount still
+ * match the current form values. A mismatch means the user changed the
+ * form after the quote was generated and the quote must be refreshed.
+ *
+ * @param {object|null} quote
+ * @param {string} from
+ * @param {string} to
+ * @param {number|string} amount
+ * @returns {boolean}
+ */
+export function isQuoteStale(quote, from, to, amount) {
+ if (!quote) return true;
+ const canonical = roundTo(Number(amount) || 0, 2);
+ return (
+ quote.from !== from ||
+ quote.to !== to ||
+ quote.sendAmount !== canonical
+ );
+}
diff --git a/test/services/quote.test.js b/test/services/quote.test.js
new file mode 100644
index 0000000..6ecc236
--- /dev/null
+++ b/test/services/quote.test.js
@@ -0,0 +1,402 @@
+/**
+ * Tests for the FX quote service (src/services/quote.js).
+ *
+ * Coverage areas:
+ * 1. Quote structure and field population (quoteId, source, timestamp, expiry, metadata)
+ * 2. Expiry: isQuoteExpired() with real and fake timers
+ * 3. Staleness: isQuoteStale() β changed amount, from, or to
+ * 4. Currency matrix: all supported corridors produce a valid quote; unsupported pairs return null
+ * 5. Precision: sendAmount, fee, amountAfterFee, receiveAmount all round to 2 dp
+ * 6. Regression: the original failure mode (submitting with expired / stale quote)
+ */
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ buildQuote,
+ calculateFee,
+ generateQuoteId,
+ isQuoteExpired,
+ isQuoteStale,
+ QUOTE_SOURCE,
+ QUOTE_TTL_MS,
+} from '../../src/services/quote.js';
+import { listRatedCurrencies } from '../../src/services/fx.js';
+import { CURRENCIES } from '../../src/constants/currencies.js';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Build a quote with a known timestamp for deterministic tests. */
+function buildAt(amount, from, to, overrideNow = Date.now()) {
+ vi.setSystemTime(overrideNow);
+ return buildQuote(amount, from, to);
+}
+
+// ---------------------------------------------------------------------------
+// 1. Quote structure
+// ---------------------------------------------------------------------------
+describe('buildQuote β quote structure', () => {
+ it('returns null for an unsupported currency pair', () => {
+ expect(buildQuote(100, 'USD', 'XYZ')).toBeNull();
+ expect(buildQuote(100, 'ABC', 'EUR')).toBeNull();
+ });
+
+ it('returns null for a zero or negative amount', () => {
+ expect(buildQuote(0, 'USD', 'NGN')).not.toBeNull(); // 0 is technically valid input, but sendAmount will be 0
+ // The service accepts 0 β callers guard against it
+ });
+
+ it('includes all required fields', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(q).not.toBeNull();
+ expect(q).toHaveProperty('quoteId');
+ expect(q).toHaveProperty('source', QUOTE_SOURCE);
+ expect(q).toHaveProperty('timestamp');
+ expect(q).toHaveProperty('expiresAt');
+ expect(q).toHaveProperty('from', 'USD');
+ expect(q).toHaveProperty('to', 'NGN');
+ expect(q).toHaveProperty('fromMeta');
+ expect(q).toHaveProperty('toMeta');
+ expect(q).toHaveProperty('rate');
+ expect(q).toHaveProperty('sendAmount');
+ expect(q).toHaveProperty('fee');
+ expect(q).toHaveProperty('amountAfterFee');
+ expect(q).toHaveProperty('receiveAmount');
+ });
+
+ it('expiresAt is timestamp + QUOTE_TTL_MS', () => {
+ const before = Date.now();
+ const q = buildQuote(100, 'USD', 'NGN');
+ const after = Date.now();
+ expect(q.expiresAt).toBeGreaterThanOrEqual(before + QUOTE_TTL_MS);
+ expect(q.expiresAt).toBeLessThanOrEqual(after + QUOTE_TTL_MS);
+ });
+
+ it('fromMeta and toMeta contain the correct currency metadata', () => {
+ const q = buildQuote(50, 'USD', 'EUR');
+ expect(q.fromMeta).toMatchObject({ code: 'USD', name: 'US Dollar', flag: 'πΊπΈ' });
+ expect(q.toMeta).toMatchObject({ code: 'EUR', name: 'Euro', flag: 'πͺπΊ' });
+ });
+
+ it('each call generates a unique quoteId', () => {
+ const ids = new Set(
+ Array.from({ length: 20 }, () => buildQuote(100, 'USD', 'NGN').quoteId),
+ );
+ expect(ids.size).toBe(20);
+ });
+
+ it('quoteId starts with "q_"', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(q.quoteId).toMatch(/^q_/);
+ });
+
+ it('source equals QUOTE_SOURCE constant', () => {
+ const q = buildQuote(100, 'USD', 'EUR');
+ expect(q.source).toBe(QUOTE_SOURCE);
+ expect(QUOTE_SOURCE).toBeTruthy();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 2. Expiry β isQuoteExpired()
+// ---------------------------------------------------------------------------
+describe('isQuoteExpired', () => {
+ beforeAll(() => {
+ vi.useFakeTimers();
+ });
+
+ afterAll(() => {
+ vi.useRealTimers();
+ });
+
+ it('returns true for null', () => {
+ expect(isQuoteExpired(null)).toBe(true);
+ });
+
+ it('returns false for a freshly built quote', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteExpired(q)).toBe(false);
+ });
+
+ it('returns false just before the TTL elapses', () => {
+ const now = Date.now();
+ const q = buildQuote(100, 'USD', 'NGN');
+ vi.setSystemTime(now + QUOTE_TTL_MS - 1);
+ expect(isQuoteExpired(q)).toBe(false);
+ });
+
+ it('returns true exactly at the TTL boundary', () => {
+ const now = Date.now();
+ const q = buildQuote(100, 'USD', 'NGN');
+ vi.setSystemTime(now + QUOTE_TTL_MS);
+ expect(isQuoteExpired(q)).toBe(true);
+ });
+
+ it('returns true after the TTL has elapsed', () => {
+ const now = Date.now();
+ const q = buildQuote(100, 'USD', 'NGN');
+ vi.setSystemTime(now + QUOTE_TTL_MS + 5000);
+ expect(isQuoteExpired(q)).toBe(true);
+ });
+
+ it('QUOTE_TTL_MS is 30 seconds', () => {
+ expect(QUOTE_TTL_MS).toBe(30_000);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 3. Staleness β isQuoteStale()
+// ---------------------------------------------------------------------------
+describe('isQuoteStale', () => {
+ it('returns true for a null quote', () => {
+ expect(isQuoteStale(null, 'USD', 'NGN', 100)).toBe(true);
+ });
+
+ it('returns false when amount, from, and to match the quote', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteStale(q, 'USD', 'NGN', 100)).toBe(false);
+ // Also with string amount that rounds to same value
+ expect(isQuoteStale(q, 'USD', 'NGN', '100')).toBe(false);
+ expect(isQuoteStale(q, 'USD', 'NGN', '100.00')).toBe(false);
+ });
+
+ it('returns true when amount has changed', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteStale(q, 'USD', 'NGN', 200)).toBe(true);
+ expect(isQuoteStale(q, 'USD', 'NGN', 99.99)).toBe(true);
+ });
+
+ it('returns true when source currency has changed', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteStale(q, 'EUR', 'NGN', 100)).toBe(true);
+ });
+
+ it('returns true when destination currency has changed', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteStale(q, 'USD', 'INR', 100)).toBe(true);
+ });
+
+ it('returns true when both currencies have changed (swap scenario)', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ // User swapped: from=NGN, to=USD
+ expect(isQuoteStale(q, 'NGN', 'USD', 100)).toBe(true);
+ });
+
+ it('handles floating-point amounts that round to the same canonical value', () => {
+ // 100.004 rounds to 100.00 β same as the quote's sendAmount of 100
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteStale(q, 'USD', 'NGN', 100.004)).toBe(false);
+ // 100.005 rounds up to 100.01 β stale
+ expect(isQuoteStale(q, 'USD', 'NGN', 100.005)).toBe(true);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 4. Currency matrix
+// ---------------------------------------------------------------------------
+describe('buildQuote β currency matrix', () => {
+ const ratedCodes = listRatedCurrencies();
+
+ // Every supported β supported pair should produce a non-null quote.
+ it.each(
+ ratedCodes.flatMap((from) =>
+ ratedCodes
+ .filter((to) => to !== from)
+ .map((to) => [from, to]),
+ ),
+ )('produces a valid quote for %s β %s', (from, to) => {
+ const q = buildQuote(100, from, to);
+ expect(q).not.toBeNull();
+ expect(q.from).toBe(from);
+ expect(q.to).toBe(to);
+ expect(q.receiveAmount).toBeGreaterThan(0);
+ });
+
+ it('returns null for any pair involving an unsupported currency code', () => {
+ const unsupported = ['ZZZ', 'BTC', 'AED', 'CAD'];
+ for (const code of unsupported) {
+ expect(buildQuote(100, code, 'USD')).toBeNull();
+ expect(buildQuote(100, 'USD', code)).toBeNull();
+ }
+ });
+
+ it('returns null for same-currency pair', () => {
+ // same-pair: getRate returns 1 but we don't explicitly block it in quote.js;
+ // however sending USDβUSD is caught by form validation, not here.
+ // Verify the quote service still returns a value (validation is the form's job).
+ const q = buildQuote(100, 'USD', 'USD');
+ // getRate('USD','USD') = 1 so this succeeds β form rejects it later.
+ expect(q).not.toBeNull();
+ expect(q.rate).toBe(1);
+ });
+
+ it('currency metadata appears for every CURRENCIES entry used in a quote', () => {
+ for (const { code } of CURRENCIES) {
+ const otherCode = code === 'USD' ? 'EUR' : 'USD';
+ const q = buildQuote(100, code, otherCode);
+ if (q) {
+ expect(q.fromMeta).toMatchObject({ code });
+ expect(q.toMeta).toMatchObject({ code: otherCode });
+ }
+ }
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 5. Precision
+// ---------------------------------------------------------------------------
+describe('buildQuote β amount precision', () => {
+ it('sendAmount is rounded to 2 decimal places', () => {
+ const q = buildQuote('123.456789', 'USD', 'NGN');
+ expect(q.sendAmount).toBe(123.46);
+ });
+
+ it('fee is rounded to 2 decimal places', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(Number.isFinite(q.fee)).toBe(true);
+ expect(q.fee).toBe(Math.round(q.fee * 100) / 100);
+ });
+
+ it('amountAfterFee is rounded to 2 decimal places', () => {
+ const q = buildQuote('50.125', 'USD', 'NGN');
+ expect(q.amountAfterFee).toBe(Math.round(q.amountAfterFee * 100) / 100);
+ });
+
+ it('receiveAmount is rounded to 2 decimal places', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(q.receiveAmount).toBe(Math.round(q.receiveAmount * 100) / 100);
+ });
+
+ it('sendAmount matches the 2dp canonical round of the input', () => {
+ // 99.999 β 100.00
+ const q = buildQuote('99.999', 'USD', 'EUR');
+ expect(q.sendAmount).toBe(100.00);
+ });
+
+ it('calculateFee respects MIN_FEE for tiny amounts', () => {
+ // 0.01 * 0.5% + 0.10 = 0.10005, but min fee is 0.25
+ expect(calculateFee(0.01)).toBe(0.25);
+ });
+
+ it('amountAfterFee is never negative', () => {
+ const q = buildQuote(0.01, 'USD', 'NGN');
+ expect(q.amountAfterFee).toBeGreaterThanOrEqual(0);
+ });
+
+ it('receiveAmount is consistent with rate Γ amountAfterFee (within floating-point tolerance)', () => {
+ const q = buildQuote(200, 'USD', 'NGN');
+ // rate is 1480.5 for USDβNGN
+ const expected = Math.round(q.amountAfterFee * q.rate * 100) / 100;
+ expect(q.receiveAmount).toBe(expected);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 6. generateQuoteId unit tests
+// ---------------------------------------------------------------------------
+describe('generateQuoteId', () => {
+ it('always starts with "q_"', () => {
+ for (let i = 0; i < 10; i++) {
+ expect(generateQuoteId()).toMatch(/^q_/);
+ }
+ });
+
+ it('produces unique IDs across rapid calls', () => {
+ const ids = new Set(Array.from({ length: 100 }, generateQuoteId));
+ expect(ids.size).toBe(100);
+ });
+
+ it('returns a non-empty string', () => {
+ const id = generateQuoteId();
+ expect(typeof id).toBe('string');
+ expect(id.length).toBeGreaterThan(3);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 7. Regression: original failure mode
+// Users could sign a transfer with an expired or mismatched quote because
+// handleSubmit called buildQuote(amount) unconditionally from the debounced
+// form state rather than re-validating the live quote.
+//
+// The fix: isQuoteExpired / isQuoteStale guards cause handleSubmit to rebuild
+// the quote when needed. We test the service helpers here (UI integration
+// test in send-money-form.test.jsx covers the full flow).
+// ---------------------------------------------------------------------------
+describe('Regression: expired / stale quote detection', () => {
+ beforeAll(() => {
+ vi.useFakeTimers();
+ });
+
+ afterAll(() => {
+ vi.useRealTimers();
+ });
+
+ it('detects expiry so the submission layer can refresh before sending', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+
+ // Before expiry β quote is usable.
+ expect(isQuoteExpired(q)).toBe(false);
+
+ // Fast-forward past the TTL β should now be rejected.
+ vi.advanceTimersByTime(QUOTE_TTL_MS + 1);
+ expect(isQuoteExpired(q)).toBe(true);
+ });
+
+ it('detects field change so the submission layer can refresh before sending', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+
+ // Quote matches current inputs β OK.
+ expect(isQuoteStale(q, 'USD', 'NGN', 100)).toBe(false);
+
+ // User changed the amount field β stale.
+ expect(isQuoteStale(q, 'USD', 'NGN', 150)).toBe(true);
+
+ // User changed destination β stale.
+ expect(isQuoteStale(q, 'USD', 'INR', 100)).toBe(true);
+
+ // User swapped currencies β stale.
+ expect(isQuoteStale(q, 'NGN', 'USD', 100)).toBe(true);
+ });
+
+ it('a freshly rebuilt quote is not expired and not stale for the same inputs', () => {
+ const q1 = buildQuote(100, 'USD', 'NGN');
+ vi.advanceTimersByTime(QUOTE_TTL_MS + 1);
+
+ // Rebuild at the (now-advanced) time.
+ const q2 = buildQuote(100, 'USD', 'NGN');
+ expect(isQuoteExpired(q2)).toBe(false);
+ expect(isQuoteStale(q2, 'USD', 'NGN', 100)).toBe(false);
+ });
+
+ it('a quote built with the current amount is not stale even after a prior stale check', () => {
+ const q = buildQuote(200, 'USD', 'EUR');
+ // Check staleness for a *different* amount (stale).
+ expect(isQuoteStale(q, 'USD', 'EUR', 300)).toBe(true);
+ // But against its own amount it's still fresh.
+ expect(isQuoteStale(q, 'USD', 'EUR', 200)).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 8. Quote-binding: quoteId is present and stable within a quote snapshot
+// ---------------------------------------------------------------------------
+describe('quoteId binding', () => {
+ it('quoteId does not change if you access it multiple times on the same object', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ expect(q.quoteId).toBe(q.quoteId);
+ });
+
+ it('two quotes for the same inputs have different quoteIds', () => {
+ const q1 = buildQuote(100, 'USD', 'NGN');
+ const q2 = buildQuote(100, 'USD', 'NGN');
+ expect(q1.quoteId).not.toBe(q2.quoteId);
+ });
+
+ it('quoteId can be serialized and recovered from JSON (for API payloads)', () => {
+ const q = buildQuote(100, 'USD', 'NGN');
+ const serialized = JSON.stringify({ quoteId: q.quoteId });
+ const deserialized = JSON.parse(serialized);
+ expect(deserialized.quoteId).toBe(q.quoteId);
+ });
+});