Mesrai
Back to blog
// essayTechnical Deep Dive

Currency Conversion at Read Time vs Write Time: The Drift Bug

Real PR catch: FX at report time — historical totals drift. Write-time capture fix with schema change.

Mesrai TeamAugust 23, 20268 min read

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

tsreports/inr-conversion.ts
// 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:

textexploit
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

textreview-comment.txt
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

tsmigrations/orders_add_fx.sql (fixed)
-- 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_at

Schema 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.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.