Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project are documented here. The format is based
on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to
[Semantic Versioning](https://semver.org/).

## Unreleased

### Added
- Centralize amount/currency validation via a shared currency policy (minor units, min/max, unsupported codes) so quote preview matches transfer settlement (#130).

## Release Process

When preparing a new release:
Expand Down
60 changes: 39 additions & 21 deletions src/services/quoteService.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,71 @@
const config = require('../config');
const rateService = require('./rateService');
const money = require('../utils/money');
const currency = require('../utils/currency');
const currencyPolicy = require('../utils/currencyPolicy');
const ApiError = require('../utils/ApiError');

/**
* Quote calculation.
* A quote tells the sender how much the recipient will receive after
* RemitFlow's fee and the FX conversion are applied.
*
* Amounts are canonicalized through `currencyPolicy` so the send amount
* recorded on a transfer matches the amount this preview returned.
*/

/**
* Compute the fee charged on a send amount.
* Fee is a percentage of the amount plus a small flat component.
* @param {number} amount - amount in the source currency.
* Fee is a percentage of the amount plus a small flat component, rounded
* to the source currency's minor units.
* @param {number} amount - canonical amount in the source currency.
* @param {string} fromCode
* @returns {number}
*/
function calculateFee(amount) {
const percentFee = money.percentage(amount, config.fee.percent);
return money.round(percentFee + config.fee.flat);
function calculateFee(amount, fromCode = config.baseCurrency) {
const percentFee = money.percentage(amount, config.fee.percent, fromCode);
return currencyPolicy.roundToCurrency(percentFee + config.fee.flat, fromCode);
}

/**
* Build a full quote for converting `amount` from `from` to `to`.
* @param {number} amount
* @param {number|string} amount
* @param {string} from
* @param {string} to
* @returns {object} quote breakdown.
*/
function getQuote(amount, from, to) {
if (!money.isPositiveAmount(amount)) {
throw ApiError.badRequest('amount must be a positive number');
const canonical = currencyPolicy.canonicalizeAmount(amount, from, {
enforceMax: true,
});
if (!canonical.ok) {
throw ApiError.badRequest(canonical.errors[0] || 'Invalid amount');
}
if (!money.isSafeAmount(amount)) {
throw ApiError.badRequest('amount is outside the supported numeric range');

if (!currencyPolicy.isSupported(to)) {
throw ApiError.badRequest(`Unsupported target currency: ${to}`);
}
if (!money.hasValidPrecision(amount)) {
throw ApiError.badRequest(
`amount must have at most ${money.DECIMALS} decimal places`
);

const fromCode = canonical.currency;
const toMeta = currencyPolicy.getMeta(to);
const toCode = toMeta.code;
const numericAmount = canonical.amount;

if (fromCode === toCode) {
throw ApiError.badRequest('from and to currencies must differ');
}

const fromCode = currency.normalize(from);
const toCode = currency.normalize(to);
const numericAmount = money.round(Number(amount));
const fee = calculateFee(numericAmount);
const amountAfterFee = money.round(numericAmount - fee);
const fee = calculateFee(numericAmount, fromCode);
const amountAfterFee = currencyPolicy.roundToCurrency(
numericAmount - fee,
fromCode
);
const rate = rateService.getRate(fromCode, toCode);
const receiveAmount = money.round(amountAfterFee * rate);
// Receive side rounds to the *destination* currency's minor units so a
// JPY payout never carries fractional yen that settlement cannot pay.
const receiveAmount = currencyPolicy.roundToCurrency(
amountAfterFee * rate,
toCode
);

return {
from: fromCode,
Expand Down
4 changes: 3 additions & 1 deletion src/services/rateService.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ function getRate(from, to) {
*/
function convert(amount, from, to) {
const rate = getRate(from, to);
return money.round(amount * rate);
const currencyPolicy = require('../utils/currencyPolicy');
const toCode = currency.normalize(to);
return currencyPolicy.roundToCurrency(amount * rate, toCode);
}

/**
Expand Down
278 changes: 278 additions & 0 deletions src/utils/currencyPolicy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
'use strict';

/**
* Canonical currency metadata shared by API validators and settlement.
*
* Every supported currency declares its ISO minor-unit precision, inclusive
* minimum send amount, and (optional) hard maximum. Amounts are validated and
* rounded against this table so preview quotes and executed transfers never
* disagree on precision or supported codes.
*/

const { SUPPORTED_CURRENCIES } = require('../config/rates');
const config = require('../config');
const currency = require('./currency');

/** @typedef {{ code: string, minorUnits: number, minAmount: number, maxAmount: number|null }} CurrencyMeta */

/**
* Minor units follow ISO 4217 where RemitFlow lists the currency.
* JPY is zero-decimal; every other corridor currency RemitFlow supports today
* is two-decimal. Unknown codes are rejected by `getMeta`, not silently
* defaulted, so a typo cannot settle with the wrong precision.
*
* @type {Record<string, CurrencyMeta>}
*/
const CURRENCY_META = Object.freeze({
USD: { code: 'USD', minorUnits: 2, minAmount: 0.01, maxAmount: null },
EUR: { code: 'EUR', minorUnits: 2, minAmount: 0.01, maxAmount: null },
GBP: { code: 'GBP', minorUnits: 2, minAmount: 0.01, maxAmount: null },
INR: { code: 'INR', minorUnits: 2, minAmount: 0.01, maxAmount: null },
NGN: { code: 'NGN', minorUnits: 2, minAmount: 0.01, maxAmount: null },
JPY: { code: 'JPY', minorUnits: 0, minAmount: 1, maxAmount: null },
PHP: { code: 'PHP', minorUnits: 2, minAmount: 0.01, maxAmount: null },
MXN: { code: 'MXN', minorUnits: 2, minAmount: 0.01, maxAmount: null },
KES: { code: 'KES', minorUnits: 2, minAmount: 0.01, maxAmount: null },
});

/**
* Ensure the policy table stays aligned with the FX rate table. A currency
* present in rates but missing here (or vice versa) is a programming error
* that must fail closed at boot rather than settle with wrong precision.
*/
function assertPolicyCoversRates() {
for (const code of SUPPORTED_CURRENCIES) {
if (!CURRENCY_META[code]) {
throw new Error(`currencyPolicy: missing metadata for rate-listed currency ${code}`);
}
}
for (const code of Object.keys(CURRENCY_META)) {
if (!SUPPORTED_CURRENCIES.includes(code)) {
throw new Error(`currencyPolicy: metadata for unlisted currency ${code}`);
}
}
}

assertPolicyCoversRates();

/**
* @param {*} code
* @returns {boolean}
*/
function isSupported(code) {
const normalized = currency.normalize(code);
return Object.prototype.hasOwnProperty.call(CURRENCY_META, normalized);
}

/**
* @param {*} code
* @returns {CurrencyMeta}
*/
function getMeta(code) {
const normalized = currency.normalize(code);
const meta = CURRENCY_META[normalized];
if (!meta) {
const err = new Error(`Unsupported currency: ${code == null || code === '' ? '(empty)' : code}`);
err.code = 'UNSUPPORTED_CURRENCY';
err.currency = code;
throw err;
}
return meta;
}

/**
* Effective max for a currency: per-currency override, else the global
* `config.maxTransferAmount` ceiling.
* @param {CurrencyMeta} meta
* @returns {number}
*/
function effectiveMax(meta) {
if (meta.maxAmount != null) return meta.maxAmount;
return config.maxTransferAmount;
}

/**
* Largest magnitude that still fits in Number.MAX_SAFE_INTEGER once scaled
* to the currency's minor unit.
* @param {number} minorUnits
* @returns {number}
*/
function maxSafeMagnitude(minorUnits) {
return Number.MAX_SAFE_INTEGER / 10 ** minorUnits;
}

/**
* Round `amount` to the currency's minor-unit precision.
* @param {number} amount
* @param {string} code
* @returns {number}
*/
function roundToCurrency(amount, code) {
const { minorUnits } = getMeta(code);
const factor = 10 ** minorUnits;
return Math.round((Number(amount) + Number.EPSILON) * factor) / factor;
}

/**
* Validate and canonicalize a send amount for a currency.
* Returns `{ ok: true, amount, currency, meta }` or `{ ok: false, errors }`.
*
* @param {*} amount
* @param {*} code
* @param {{ enforceMax?: boolean }} [options]
* @returns {{ ok: true, amount: number, currency: string, meta: CurrencyMeta } | { ok: false, errors: string[] }}
*/
function canonicalizeAmount(amount, code, options = {}) {
const enforceMax = options.enforceMax !== false;
const errors = [];

if (!code && code !== 0) {
errors.push('currency is required');
return { ok: false, errors };
}

let meta;
try {
meta = getMeta(code);
} catch (err) {
errors.push(`Unsupported currency: ${code}`);
return { ok: false, errors };
}

if (amount === undefined || amount === null || amount === '') {
errors.push('amount is required');
return { ok: false, errors };
}

const n = Number(amount);
if (!Number.isFinite(n) || n <= 0) {
errors.push('amount must be a positive number');
return { ok: false, errors };
}

if (Math.abs(n) > maxSafeMagnitude(meta.minorUnits)) {
errors.push('amount is outside the supported numeric range');
return { ok: false, errors };
}

if (!hasValidPrecisionFor(amount, meta.minorUnits)) {
if (meta.minorUnits === 0) {
errors.push(`amount for ${meta.code} must be a whole number`);
} else {
errors.push(
`amount must have at most ${meta.minorUnits} decimal places for ${meta.code}`
);
}
return { ok: false, errors };
}

const canonical = roundToCurrency(n, meta.code);

if (canonical < meta.minAmount) {
errors.push(`amount must be at least ${meta.minAmount} ${meta.code}`);
return { ok: false, errors };
}

if (enforceMax) {
const max = effectiveMax(meta);
if (canonical > max) {
errors.push(`amount must not exceed ${max}`);
return { ok: false, errors };
}
}

return { ok: true, amount: canonical, currency: meta.code, meta };
}

/**
* @param {*} value
* @param {number} decimals
* @returns {boolean}
*/
function hasValidPrecisionFor(value, decimals) {
if (typeof value !== 'number' && typeof value !== 'string') {
return false;
}
const str = String(value).trim();
if (!/^-?\d+(\.\d+)?$/.test(str)) {
return false;
}
const fraction = str.split('.')[1] || '';
return fraction.length <= decimals;
}

/**
* Collect validation errors for a from/to transfer or quote pair.
* @param {*} amount
* @param {*} from
* @param {*} to
* @param {{ enforceMax?: boolean, amountLabel?: string }} [options]
* @returns {string[]}
*/
function validateTransferPair(amount, from, to, options = {}) {
const errors = [];
const amountLabel = options.amountLabel || 'amount';

if (!from) {
errors.push('from currency is required');
} else if (!isSupported(from)) {
errors.push(`Unsupported source currency: ${from}`);
}

if (!to) {
errors.push('to currency is required');
} else if (!isSupported(to)) {
errors.push(`Unsupported target currency: ${to}`);
}

if (from && to && currency.normalize(from) && currency.normalize(to)
&& currency.normalize(from) === currency.normalize(to)) {
errors.push('from and to currencies must differ');
}

// Only canonicalize the send amount against the source currency once the
// source code itself is known/supported — otherwise we would double-report.
if (from && isSupported(from)) {
const result = canonicalizeAmount(amount, from, {
enforceMax: options.enforceMax,
});
if (!result.ok) {
for (const e of result.errors) {
// Rewrite generic "currency is required" / bare amount messages with
// the caller's label when useful; keep specific currency messages.
if (e === 'amount is required') {
errors.push(`${amountLabel} is required`);
} else if (e === 'amount must be a positive number') {
errors.push(`${amountLabel} must be a positive number`);
} else if (e === 'amount is outside the supported numeric range') {
errors.push(`${amountLabel} is outside the supported numeric range`);
} else {
errors.push(e.replace(/^amount/, amountLabel));
}
}
}
} else if (amount === undefined || amount === null || amount === '') {
errors.push(`${amountLabel} is required`);
} else if (!Number.isFinite(Number(amount)) || Number(amount) <= 0) {
// Surface a basic amount error even when the currency is missing so the
// client learns both problems in one round-trip.
const n = Number(amount);
if (!Number.isFinite(n) || n <= 0) {
errors.push(`${amountLabel} must be a positive number`);
}
}

return errors;
}

module.exports = {
CURRENCY_META,
isSupported,
getMeta,
effectiveMax,
maxSafeMagnitude,
roundToCurrency,
canonicalizeAmount,
hasValidPrecisionFor,
validateTransferPair,
};
Loading