diff --git a/src/__tests__/integration/wallet-ingest.test.ts b/src/__tests__/integration/wallet-ingest.test.ts index d2c984e..c745845 100644 --- a/src/__tests__/integration/wallet-ingest.test.ts +++ b/src/__tests__/integration/wallet-ingest.test.ts @@ -208,6 +208,45 @@ describe("currency", () => { const [m] = await metas(); // Says so, rather than inventing a rate. 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); }); }); diff --git a/src/lib/wallet-descriptor.ts b/src/lib/wallet-descriptor.ts index faa8e7a..936a9f3 100644 --- a/src/lib/wallet-descriptor.ts +++ b/src/lib/wallet-descriptor.ts @@ -111,6 +111,10 @@ export function currencyFromAmountText(amountText: string | null | undefined): s if (/^฿/.test(t)) return "THB"; if (/^S\$/i.test(t)) return "SGD"; 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. return null; } diff --git a/src/lib/wallet-ingestion.ts b/src/lib/wallet-ingestion.ts index 3e5d686..4e5702f 100644 --- a/src/lib/wallet-ingestion.ts +++ b/src/lib/wallet-ingestion.ts @@ -80,6 +80,10 @@ async function medianRateToAud(code: string): Promise { 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 { * 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