diff --git a/src/components/TextField.jsx b/src/components/TextField.jsx index 6e1bc36..a242d26 100644 --- a/src/components/TextField.jsx +++ b/src/components/TextField.jsx @@ -8,6 +8,7 @@ import './TextField.css'; * @param {Function} props.onChange - called with the raw string value * @param {string} [props.id] * @param {string} [props.type] + * @param {string} [props.inputMode] * @param {string} [props.placeholder] * @param {string} [props.error] - validation error to display * @param {Function} [props.onBlur] - called with the current value on blur @@ -18,6 +19,7 @@ export default function TextField({ onChange, id, type = 'text', + inputMode, placeholder, error, onBlur, @@ -34,6 +36,7 @@ export default function TextField({ id={id} className={`text-field-input ${error ? 'has-error' : ''}`} type={type} + inputMode={inputMode} value={value} placeholder={placeholder} aria-invalid={error ? 'true' : 'false'} diff --git a/src/constants/currencies.js b/src/constants/currencies.js index 4d3c62e..51fce76 100644 --- a/src/constants/currencies.js +++ b/src/constants/currencies.js @@ -1,12 +1,30 @@ // Supported currencies for RemitFlow transfers. export const CURRENCIES = [ - { code: 'USD', name: 'US Dollar', symbol: '$', flag: '🇺🇸' }, - { code: 'EUR', name: 'Euro', symbol: '€', flag: '🇪🇺' }, - { code: 'GBP', name: 'British Pound', symbol: '£', flag: '🇬🇧' }, - { code: 'NGN', name: 'Nigerian Naira', symbol: '₦', flag: '🇳🇬' }, - { code: 'INR', name: 'Indian Rupee', symbol: '₹', flag: '🇮🇳' }, - { code: 'PHP', name: 'Philippine Peso', symbol: '₱', flag: '🇵🇭' }, - { code: 'MXN', name: 'Mexican Peso', symbol: '$', flag: '🇲🇽' }, + { code: 'USD', name: 'US Dollar', symbol: '$', flag: '🇺🇸', minorUnits: 2 }, + { code: 'EUR', name: 'Euro', symbol: '€', flag: '🇪🇺', minorUnits: 2 }, + { + code: 'GBP', + name: 'British Pound', + symbol: '£', + flag: '🇬🇧', + minorUnits: 2, + }, + { + code: 'NGN', + name: 'Nigerian Naira', + symbol: '₦', + flag: '🇳🇬', + minorUnits: 2, + }, + { code: 'INR', name: 'Indian Rupee', symbol: '₹', flag: '🇮🇳', minorUnits: 2 }, + { + code: 'PHP', + name: 'Philippine Peso', + symbol: '₱', + flag: '🇵🇭', + minorUnits: 2, + }, + { code: 'MXN', name: 'Mexican Peso', symbol: '$', flag: '🇲🇽', minorUnits: 2 }, ]; export const DEFAULT_SOURCE = 'USD'; @@ -30,3 +48,7 @@ export const POPULAR_CORRIDORS = [ export function getCurrency(code) { return CURRENCIES.find((c) => c.code === code); } + +export function getCurrencyMinorUnits(code) { + return getCurrency(code)?.minorUnits ?? 2; +} diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index 344b26c..ea6da14 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -6,7 +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 { formatCurrencyInput } from '../utils/format.js'; +import { formatCurrencyInput, parseCurrencyInput } from '../utils/format.js'; import { isPositiveAmount, validateRecipient, @@ -42,9 +42,13 @@ export default function SendMoney() { // Recompute the quote whenever the (debounced) inputs change. const quote = useMemo(() => { - if (!isPositiveAmount(debouncedAmount)) return null; - return buildQuote(debouncedAmount, from, to); - }, [debouncedAmount, from, to]); + const parsed = parseCurrencyInput(debouncedAmount, { + currency: from, + locale, + }); + if (!parsed.ok) return null; + return buildQuote(parsed.value, from, to); + }, [debouncedAmount, from, locale, to]); function swapCurrencies() { setFrom(to); @@ -53,7 +57,7 @@ export default function SendMoney() { // Tidy the amount field to two decimals once the user leaves it. function handleAmountBlur(value) { - const formatted = formatCurrencyInput(value); + const formatted = formatCurrencyInput(value, from, locale); if (formatted) setAmount(formatted); } @@ -62,9 +66,16 @@ export default function SendMoney() { if (!validateRecipient(recipient)) { next.recipient = 'Enter a valid email or Stellar address.'; } - if (!isPositiveAmount(amount)) { - next.amount = 'Enter an amount greater than zero.'; - } else if (wallet && !isWithinBalance(amount, wallet.balance)) { + const parsedAmount = parseCurrencyInput(amount, { currency: from, locale }); + if (!parsedAmount.ok) { + next.amount = parsedAmount.error; + } else if ( + wallet && + !isWithinBalance(parsedAmount.value, wallet.balance, { + currency: from, + locale, + }) + ) { next.amount = 'Amount exceeds your wallet balance.'; } if (from === to) { @@ -99,7 +110,12 @@ 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); + const parsedAmount = parseCurrencyInput(amount, { + currency: from, + locale, + }); + if (!parsedAmount.ok) return; + const finalQuote = buildQuote(parsedAmount.value, from, to); if (!finalQuote) return; await addTransfer({ @@ -151,7 +167,7 @@ export default function SendMoney() { part.type === 'decimal')?.value ?? '.', + group: parts.find((part) => part.type === 'group')?.value ?? ',', + }); + } + return DECIMAL_CACHE.get(locale); +} + +function normaliseDigits(value) { + return Array.from(String(value), (char) => DIGIT_MAP.get(char) ?? char).join( + '', + ); +} + +/** + * Return the canonical decimal-string precision for a currency. + * @param {string} currency - ISO currency code + * @returns {number} number of minor-unit decimal places + */ +export function getCurrencyPrecision(currency = 'USD') { + return getCurrencyMinorUnits(currency); +} + +/** + * Parse a user-entered amount into a canonical decimal string. + * Locale grouping is removed, locale decimals are converted to '.', precision + * beyond the currency minor unit is rejected, and negative/zero values can be + * rejected by the caller through options. + * @param {string|number} value - raw amount input + * @param {object} [options] + * @param {string} [options.currency] - ISO currency code + * @param {string} [options.locale] - BCP 47 locale tag + * @param {boolean} [options.allowZero] + * @param {boolean} [options.allowNegative] + * @returns {{ok: true, value: string, minorUnits: bigint, precision: number}|{ok: false, error: string}} + */ +export function parseCurrencyInput(value, options = {}) { + const { + currency = 'USD', + locale = DEFAULT_LOCALE, + allowZero = false, + allowNegative = false, + } = options; + const precision = getCurrencyPrecision(currency); + const { decimal, group } = getLocaleSeparators(locale); + let input = normaliseDigits(value ?? '').trim(); + input = input.replace(/[\s\u00a0\u202f]/g, ''); + if (group) input = input.split(group).join(''); + if (decimal !== '.') input = input.split(decimal).join('.'); + if (!input) return { ok: false, error: 'Enter an amount greater than zero.' }; + const negative = input.startsWith('-'); + if (negative) input = input.slice(1); + if (input.startsWith('+')) input = input.slice(1); + if (negative && !allowNegative) { + return { ok: false, error: 'Amount cannot be negative.' }; + } + if (!/^\d*(\.\d*)?$/.test(input) || input === '.' || input === '') { + return { ok: false, error: 'Enter a valid amount.' }; + } + let [whole = '0', fraction = ''] = input.split('.'); + whole = whole.replace(/^0+(?=\d)/, '') || '0'; + if (fraction.length > precision) { + return { + ok: false, + error: `${currency} supports at most ${precision} decimal places.`, + }; + } + const paddedFraction = fraction.padEnd(precision, '0'); + const minorUnits = BigInt(whole + paddedFraction || '0'); + if (minorUnits === 0n && !allowZero) { + return { ok: false, error: 'Enter an amount greater than zero.' }; + } + const sign = negative ? '-' : ''; + const canonical = `${sign}${whole}.${paddedFraction}`; + return { ok: true, value: canonical, minorUnits, precision }; +} + +function decimalStringToNumber(value) { + if (typeof value === 'bigint') return Number(value); + const parsed = parseCurrencyInput(value, { + allowZero: true, + allowNegative: true, + }); + return parsed.ok ? Number(parsed.value) : Number(value) || 0; +} + /** * Format an amount as a currency string. * @param {number|string} amount - the amount to format @@ -14,12 +116,13 @@ export function formatAmount( currency = 'USD', locale = DEFAULT_LOCALE, ) { - const num = Number(amount) || 0; + const precision = getCurrencyPrecision(currency); + const num = decimalStringToNumber(amount); return new Intl.NumberFormat(locale, { style: 'currency', currency, - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: precision, + maximumFractionDigits: precision, }).format(num); } @@ -52,19 +155,23 @@ export function formatRate(rate, from, 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"). + * Normalise a raw amount string into a canonical, fixed-precision value. * @param {string} value - the raw input value - * @returns {string} the cleaned amount, or '' if the input has no digits + * @param {string} [currency] - ISO currency code + * @param {string} [locale] - BCP 47 locale tag + * @returns {string} the cleaned amount, or '' when the input is invalid */ -export function formatCurrencyInput(value) { - 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); +export function formatCurrencyInput( + value, + currency = 'USD', + locale = DEFAULT_LOCALE, +) { + const parsed = parseCurrencyInput(value, { + currency, + locale, + allowZero: true, + }); + return parsed.ok ? parsed.value : ''; } /** diff --git a/src/utils/validate.js b/src/utils/validate.js index e254294..3ed04a5 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -1,8 +1,8 @@ // Simple validation helpers for the Send Money form. +import { parseCurrencyInput } from './format.js'; -export function isPositiveAmount(value) { - const num = Number(value); - return Number.isFinite(num) && num > 0; +export function isPositiveAmount(value, options = {}) { + return parseCurrencyInput(value, options).ok; } export function isEmail(value) { @@ -23,8 +23,12 @@ export function validateRecipient(value) { * @param {number} balance * @returns {boolean} */ -export function isWithinBalance(amount, balance) { - const num = Number(amount); - if (!Number.isFinite(num)) return false; - return num <= Number(balance); +export function isWithinBalance(amount, balance, options = {}) { + const parsedAmount = parseCurrencyInput(amount, options); + const parsedBalance = parseCurrencyInput(balance, { + ...options, + allowZero: true, + }); + if (!parsedAmount.ok || !parsedBalance.ok) return false; + return parsedAmount.minorUnits <= parsedBalance.minorUnits; } diff --git a/test/unit/format.test.js b/test/unit/format.test.js index 310b830..aa99d75 100644 --- a/test/unit/format.test.js +++ b/test/unit/format.test.js @@ -3,6 +3,8 @@ import { formatAmount, formatDate, formatNumber, + formatCurrencyInput, + parseCurrencyInput, } from '../../src/utils/format.js'; describe('formatAmount', () => { @@ -65,3 +67,38 @@ describe('formatNumber', () => { expect(result).not.toBe(formatNumber(1234.5, 2, 'en-US')); }); }); + +describe('parseCurrencyInput', () => { + const cases = [ + ['en-US', '1,234.50'], + ['fr-FR', '1 234,50'], + ['es-MX', '1,234.50'], + ['hi-IN', '1,234.50'], + ['ar-EG', '١٬٢٣٤٫٥٠'], + ]; + + it.each(cases)( + 'round-trips %s locale input to a canonical decimal string', + (locale, input) => { + const parsed = parseCurrencyInput(input, { currency: 'USD', locale }); + expect(parsed).toMatchObject({ ok: true, value: '1234.50' }); + expect(formatCurrencyInput(input, 'USD', locale)).toBe('1234.50'); + }, + ); + + it('rejects unsupported precision instead of rounding user intent', () => { + expect(parseCurrencyInput('1.239', { currency: 'USD' })).toMatchObject({ + ok: false, + error: 'USD supports at most 2 decimal places.', + }); + }); + + it('rejects zero and negative submission amounts', () => { + expect(parseCurrencyInput('0.00', { currency: 'USD' })).toMatchObject({ + ok: false, + }); + expect(parseCurrencyInput('-1.00', { currency: 'USD' })).toMatchObject({ + ok: false, + }); + }); +}); diff --git a/test/unit/quote.test.js b/test/unit/quote.test.js new file mode 100644 index 0000000..0fcfe52 --- /dev/null +++ b/test/unit/quote.test.js @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { buildQuote } from '../../src/services/quote.js'; + +describe('buildQuote', () => { + it('keeps submitted, fee, and receipt amounts as canonical decimal strings', () => { + const quote = buildQuote('100.10', 'USD', 'NGN'); + expect(quote).toMatchObject({ + sendAmount: '100.10', + fee: '0.60', + amountAfterFee: '99.50', + receiveAmount: '147309.75', + }); + }); + + it('regresses binary floating-point drift at the receipt boundary', () => { + const quote = buildQuote('0.30', 'USD', 'MXN'); + expect(quote.sendAmount).toBe('0.30'); + expect(quote.amountAfterFee).toBe('0.00'); + expect(quote.receiveAmount).toBe('0.00'); + }); +});