wallet: never store a native figure as AUD; rate from statement rows only
ci / lint-test (push) Successful in 51s

A CommBank approval prompt for 672006 VND had no VND rate history, and the
no_rate_reference branch left the native figure in transactions.amount -- the
ledger carried A$672,006 for six days (txn 7316, repaired to 35.95).

- no rate history: amount = 0, still flagged aud_provisional/no_rate_reference;
  reconcile matches on the native pair, so the statement supplies the AUD
- medianRateToAud reads statement rows only; a wallet row's amount is itself
  a guess and a 1:1 row would have poisoned the next estimate
- foreign halves pair on the native pair, since several can share amount 0
- currencyFromAmountText reads ISO codes around the figure ("672006 VND")

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 12:43:25 +10:00
co-authored by Claude Opus 5
parent 5076f27f68
commit 48c6b2395f
3 changed files with 68 additions and 4 deletions
+25 -4
View File
@@ -80,6 +80,10 @@ async function medianRateToAud(code: string): Promise<number | null> {
WHERE foreign_currency_code = $1
AND foreign_currency_amount > 0
AND superseded_by_id IS NULL
-- Statement rows only: a wallet row's amount is itself a provisional
-- guess, and one stored before this filter (txn 7316, 672006 VND at a
-- 1:1 "rate") would otherwise feed the next estimate.
AND statement_id IS NOT NULL
HAVING count(*) >= 5`,
[code]
);
@@ -96,18 +100,28 @@ async function medianRateToAud(code: string): Promise<number | null> {
* either loses a field nothing else supplies. Matched on amount and date rather
* than any id, because the notifications share no identifier.
*/
async function findPairedRow(amount: number, date: string) {
async function findPairedRow(
amount: number,
date: string,
fx: { amount: number; code: string } | null = null
) {
// A foreign row pairs on its native pair: its `amount` is a provisional
// conversion (or a 0 placeholder), so two unrelated foreign taps on one day
// could share it.
const rows = await queryRaw<{ id: number; meta_id: number | null }>(
`SELECT t.id, em.id AS meta_id
FROM transactions t
LEFT JOIN expense_metadata em ON em.transaction_id = t.id
WHERE t.source = 'wallet'
AND t.statement_id IS NULL
AND t.amount = $1::numeric
AND CASE WHEN $4::text IS NULL
THEN t.amount = $1::numeric AND t.foreign_currency_code IS NULL
ELSE t.foreign_currency_code = $4::text
AND t.foreign_currency_amount = $5::numeric END
AND t.transaction_date BETWEEN $2::date - $3::int AND $2::date + $3::int
AND t.superseded_by_id IS NULL
LIMIT 2`,
[amount, date, PAIR_WINDOW_DAYS]
[amount, date, PAIR_WINDOW_DAYS, fx?.code ?? null, fx?.amount ?? null]
);
return rows.length === 1 ? rows[0] : null;
}
@@ -217,6 +231,11 @@ export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIng
amountAud = Number((ev.amount * rate).toFixed(2));
flags.push("aud_provisional");
} else {
// No history to convert with. The native figure must NOT fall through as
// AUD -- 672006 VND once landed as A$672,006. Zero is a flagged
// placeholder; the statement supplies the real AUD at reconcile time,
// which matches on the native pair, not on this column.
amountAud = 0;
flags.push("aud_provisional", "no_rate_reference");
}
}
@@ -231,7 +250,9 @@ export async function processWalletIngestion(ev: WalletEvent): Promise<WalletIng
// tap. The issuer half brings the card tail; the Wallet half brings the
// merchant and suburb. COALESCE so whichever arrives second only ever fills
// gaps -- it can never blank a field the first half supplied.
const pair = await findPairedRow(amountAud, date);
const pair = await findPairedRow(
amountAud, date, fxCode && fxAmount !== null ? { amount: fxAmount, code: fxCode } : null
);
if (pair) {
await queryRaw(
`UPDATE transactions