wallet: never store a native figure as AUD; rate from statement rows only
ci / lint-test (push) Successful in 51s
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:
@@ -208,6 +208,45 @@ describe("currency", () => {
|
|||||||
const [m] = await metas();
|
const [m] = await metas();
|
||||||
// Says so, rather than inventing a rate.
|
// Says so, rather than inventing a rate.
|
||||||
expect(m.flags).toContain("no_rate_reference");
|
expect(m.flags).toContain("no_rate_reference");
|
||||||
|
// And never stores the native figure as AUD (672006 VND became A$672,006).
|
||||||
|
expect(Number(t.amount)).toBe(0);
|
||||||
|
expect(Number(t.foreign_currency_amount)).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the rate from statement rows only, never from wallet rows", async () => {
|
||||||
|
// Six statementless rows at a nonsense 1:1 rate must not count as history.
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO transactions (transaction_date, description, amount, transaction_type,
|
||||||
|
owner_id, foreign_currency_amount, foreign_currency_code, source, source_ref)
|
||||||
|
VALUES ('2026-01-10','VN',1000,'debit',$1,1000,'VND','wallet',$2)`,
|
||||||
|
[ownerId, `evt:seed${i}`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await ingest(req({
|
||||||
|
event_id: 997, kind: "transaction", direction: "debit", amount: 672006,
|
||||||
|
amount_text: "672006 VND", currency: "VND", merchant: "EVISA VIETNAM", notif_app: "CommBank",
|
||||||
|
occurred_at: "2026-09-05T11:22:00+10:00",
|
||||||
|
}));
|
||||||
|
const t = (await txns()).find((r) => r.source_ref === "evt:997")!;
|
||||||
|
expect(Number(t.amount)).toBe(0);
|
||||||
|
const [m] = (await metas()).filter((r) => r.source_message_id === "evt:997");
|
||||||
|
expect(m.flags).toContain("no_rate_reference");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pairs foreign halves on the native figure, not the placeholder amount", async () => {
|
||||||
|
await ingest(req({
|
||||||
|
event_id: 996, kind: "transaction", direction: "debit", amount: 42,
|
||||||
|
amount_text: "€42.00", merchant: "Cafe A", notif_app: "Wallet",
|
||||||
|
occurred_at: "2026-08-20T12:00:00+10:00",
|
||||||
|
}));
|
||||||
|
await ingest(req({
|
||||||
|
event_id: 995, kind: "transaction", direction: "debit", amount: 17,
|
||||||
|
amount_text: "€17.00", merchant: "Cafe B", notif_app: "Wallet",
|
||||||
|
occurred_at: "2026-08-20T12:05:00+10:00",
|
||||||
|
}));
|
||||||
|
// Both have amount 0; they are different purchases and must stay two rows.
|
||||||
|
expect((await txns()).filter((r) => r.foreign_currency_code === "EUR")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ export function currencyFromAmountText(amountText: string | null | undefined): s
|
|||||||
if (/^฿/.test(t)) return "THB";
|
if (/^฿/.test(t)) return "THB";
|
||||||
if (/^S\$/i.test(t)) return "SGD";
|
if (/^S\$/i.test(t)) return "SGD";
|
||||||
if (/^₹/.test(t)) return "INR";
|
if (/^₹/.test(t)) return "INR";
|
||||||
|
// CommBank's approval prompts write the ISO code after the figure
|
||||||
|
// ("672006 VND", "1340.00 INR"); some issuers put it first ("USD 10.00").
|
||||||
|
const iso = t.match(/^[\d.,\s]+([A-Z]{3})$/) ?? t.match(/^([A-Z]{3})\s*[\d.,]+$/);
|
||||||
|
if (iso) return iso[1];
|
||||||
// A bare "$" is genuinely ambiguous -- do not guess.
|
// A bare "$" is genuinely ambiguous -- do not guess.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ async function medianRateToAud(code: string): Promise<number | null> {
|
|||||||
WHERE foreign_currency_code = $1
|
WHERE foreign_currency_code = $1
|
||||||
AND foreign_currency_amount > 0
|
AND foreign_currency_amount > 0
|
||||||
AND superseded_by_id IS NULL
|
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`,
|
HAVING count(*) >= 5`,
|
||||||
[code]
|
[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
|
* either loses a field nothing else supplies. Matched on amount and date rather
|
||||||
* than any id, because the notifications share no identifier.
|
* 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 }>(
|
const rows = await queryRaw<{ id: number; meta_id: number | null }>(
|
||||||
`SELECT t.id, em.id AS meta_id
|
`SELECT t.id, em.id AS meta_id
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN expense_metadata em ON em.transaction_id = t.id
|
LEFT JOIN expense_metadata em ON em.transaction_id = t.id
|
||||||
WHERE t.source = 'wallet'
|
WHERE t.source = 'wallet'
|
||||||
AND t.statement_id IS NULL
|
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.transaction_date BETWEEN $2::date - $3::int AND $2::date + $3::int
|
||||||
AND t.superseded_by_id IS NULL
|
AND t.superseded_by_id IS NULL
|
||||||
LIMIT 2`,
|
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;
|
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));
|
amountAud = Number((ev.amount * rate).toFixed(2));
|
||||||
flags.push("aud_provisional");
|
flags.push("aud_provisional");
|
||||||
} else {
|
} 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");
|
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
|
// tap. The issuer half brings the card tail; the Wallet half brings the
|
||||||
// merchant and suburb. COALESCE so whichever arrives second only ever fills
|
// merchant and suburb. COALESCE so whichever arrives second only ever fills
|
||||||
// gaps -- it can never blank a field the first half supplied.
|
// 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) {
|
if (pair) {
|
||||||
await queryRaw(
|
await queryRaw(
|
||||||
`UPDATE transactions
|
`UPDATE transactions
|
||||||
|
|||||||
Reference in New Issue
Block a user