feat(orders): wire the real parser in, defer card reconciliation

Ingestion now runs on the rebuilt parser. Three substantive changes.

Deferred card reconciliation. A 'MasterCard 8032 and/or credits' receipt never
states the split, but the card leg lands on the statement — Subway's $29.08
order shows $13.06 on 8032, so $16.02 was credits. For a live order that
statement is weeks away, so the split cannot be settled at ingest time. Such
orders are now parked with provenance and no transaction, and
reconcilePendingOrders() resolves them once the statement arrives. Backfill
takes the same path and resolves immediately. Migration 0019 adds the columns
that make an order resumable; applied to personal_test only, prod untouched.

Payment detection bug, found by the new tests: the old regex delimited the
'Paid with' line on a double space, which whitespace collapsing removes. Every
card and mixed receipt fell through to the credits branch — the Woolworths
receipt booked $60.93 of credits spend that never happened.

Category resolution reversed deliberately. Correction 1 said never default to
dining; the implementation of that sent everything unrecognised to 'other', and
knowing six merchants meant Carl's Jr, Taco Bell, Chilli India, Oporto, Schnitz
and Souvlaki GR all landed there. Grocers are an enumerable set and restaurants
are not, so match groceries explicitly and let the residual be dining.

Tests rebuilt on real captured receipts; the synthetic fixtures are deleted.
60 unit + 41 integration green on three consecutive runs.
This commit is contained in:
2026-07-26 22:43:10 +10:00
parent 33db7d05ef
commit c82a22767f
10 changed files with 626 additions and 531 deletions
+10 -14
View File
@@ -164,26 +164,22 @@ function parsePayment(platform: string, html: string, text: string): PaymentBrea
};
if (platform === "doordash") {
const paid = text.match(/Paid with\s+([^\n]{1,120}?)(?:\s{2,}|$)/i);
const line = paid ? collapse(paid[1]) : "";
// "MasterCard Ending in 8032 and/or credits" — DoorDash names both methods
// and never the split. Resolved as card (user, 2026-07-26: the real charge
// went to 8032), which is also the conservative reading: a card order
// creates no transaction, so it cannot double-count against the statement
// line that will arrive for that card. Flagged either way.
if (/and\/or/i.test(line)) {
// Match the instrument directly. An earlier version captured a trailing
// window delimited by a double space, which does not survive whitespace
// collapsing — so every card/mixed receipt fell through to the credits
// branch and booked the full total as credits.
if (/Paid with[\s\S]{0,60}?and\/or\s*credits/i.test(text)) {
out.ambiguous = true;
const l4 = line.match(/Ending in\s*(\d{3,4})/i);
const l4 = text.match(/Paid with[\s\S]{0,60}?Ending in\s*(\d{3,4})/i);
out.card_last4 = l4 ? l4[1] : null;
return out;
}
if (/credits/i.test(line)) return out; // credits-only; amount filled from total
const l4 = line.match(/Ending in\s*(\d{3,4})/i);
if (l4) {
out.card_last4 = l4[1];
const card = text.match(/Paid with[\s\S]{0,40}?Ending in\s*(\d{3,4})/i);
if (card) {
out.card_last4 = card[1];
return out; // card-only; amount filled from total
}
if (/Paid with\s+credits/i.test(text)) return out; // credits-only
return out;
}