An INR-conversion report fetching the current FX rate at query time. Reports for the same period change every day as the rate moves. Accounting wants stable historical numbers.
The vulnerable diff
// reports/inr-conversion.ts
async function totalInr(since: Date): Promise<number> {
const usdOrders = await db.orders.findMany({ where: { currency: "USD", createdAt: { gte: since } } });
const rate = await fxClient.getRate("USD", "INR"); // BUG: rate at NOW, not at order time
return usdOrders.reduce((sum, o) => sum + o.amountUsd * rate, 0);
}What is wrong
FX rates change continuously. Converting historical amounts at the current rate means the same historical period reports a different INR total every day. Accounting needs stable historical figures: the report for September must be the same in October as it was on October 1. The fix is to capture the rate at transaction time (write) and store both the original-currency amount and the base-currency-equivalent amount. Reports then SUM the stored equivalent and produce stable numbers.
The attack
Symptom:
Day of close (Oct 1): Sept revenue (USD orders) = ₹8,42,000
Day of close (Oct 15): Same Sept revenue = ₹8,48,500 (USD rose 0.7%)
Day of close (Nov 1): Same Sept revenue = ₹8,35,000 (USD fell)
CFO: "Why are our books rewriting themselves?"
GAAP/IND AS both require period totals to be stable once closed.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Logic] [FX-Drift] [medium]
Conversion at read time means historical totals change as FX rates
move. Accounting needs stable historical numbers.
Capture rate + converted amount at transaction time:
// Order create:
const rate = await fxClient.getRate("USD", "INR");
await db.orders.create({
data: {
amount_usd_cents: amountUsdCents,
fx_usd_inr: rate, // store the rate
amount_inr_paise: Math.round(amountUsdCents * rate),
},
});
// Report query — uses the stored converted amount:
const r = await db.orders.aggregate({
_sum: { amount_inr_paise: true },
where: { createdAt: { gte: since } },
});
return r._sum.amount_inr_paise;
For un-realized FX volatility (e.g., open invoices reported in INR
mid-month before payment), the choice between booking-date rate and
period-end rate depends on your accounting policy. Document it.The fix
-- migrations/orders_add_fx.sql
ALTER TABLE orders
ADD COLUMN fx_usd_inr DECIMAL(10,4),
ADD COLUMN amount_inr_paise BIGINT;
-- backfill historic rows: lookup historical rate at created_atSchema change. New rows store the rate + converted amount at write time. Old rows backfilled with historical rates. Reports query the stored converted amount — stable forever.
Why human review missed it
FX-drift is silent until accounting compares two snapshots of the same period and finds different numbers. Mesrai catches every FX-rate fetch inside a report-generation path and recommends transaction-time capture.
Related rules + further reading
Mesrai rule pack: logic/fx-at-write-not-read — flags FX rate fetches inside aggregation/report code.
IFRS 13 / IND AS 113: fair value at transaction date.
Common in multi-currency SaaS billing.
Takeaway
Capture rate at write. Store converted amount. Reports stay stable. Mesrai catches read-time conversion.