fix(orders): two defects the backfill exposed that tests could not
ci / lint-test (push) Failing after 1m26s

Both were found by looking at the data after the live backfill, not by the
suite — 105 tests were green while 85 rows were invisible and 4 were double
counted.

owner_id was NULL on every ingested order. Analytics scope on
COALESCE(t.owner_id, s.owner_id), and an ingested order carries no statement,
so the coalesce resolved to NULL and matched no owner. The rows existed in
`transactions` and appeared in no view in the app. Ingestion now sets
DEFAULT_OWNER_ID, and a regression test asserts the row survives the same
COALESCE scoping the UI uses.

[Family] orders are card-settled, not credits-funded. Their receipts name the
payer ("Payments Siddharth LKR 3,783.20") and no instrument, which an earlier
version read as credits. The card statement carries all four of them (CBA
...3893, exact foreign_currency_amount matches), so creating a transaction
duplicated spend already recorded — the double-count I5 exists to prevent.
They now record provenance only; the statement line is the transaction and is
what carries the `family` tag that keeps them out of budgets.

Production data corrected separately by
smarthome:docker/scripts/fix-order-backfill-2026-07-27.sql.

Also: reconciliation tests no longer assert global row counts.
reconcilePendingOrders() scans every pending row, so leftovers from other
files moved the totals — the source of an intermittent failure that only
appeared on the first run after a source edit.
This commit is contained in:
2026-07-27 10:41:10 +10:00
parent 5db42f086f
commit ae0c34fce7
4 changed files with 78 additions and 56 deletions
+9 -18
View File
@@ -98,15 +98,6 @@ const decodeEntities = (s: string) =>
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
/** URL-decodes without throwing on malformed percent-escapes. */
function safeDecode(s: string): string {
try {
return decodeURIComponent(s.replace(/%(?![0-9a-f]{2})/gi, "%25"));
} catch {
return s;
}
}
const money = (raw: string): number => Math.abs(parseFloat(raw.replace(/[$,]/g, "")));
/**
@@ -181,7 +172,6 @@ function parseMerchant(platform: string, meta: MessageMeta, text: string): strin
}
function parsePayment(platform: string, html: string, text: string): PaymentBreakdown {
// eslint-disable-next-line no-param-reassign
const out: PaymentBreakdown = {
credits_amount: null,
card_amount: null,
@@ -385,14 +375,15 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
const payment = parsePayment(platform, clean, text);
if (payment.ambiguous && is_family) {
// [Family] receipts name the payer, not an instrument ("Payments Siddharth
// LKR 3,783.20"), so no split is recoverable and there is no card leg to
// reconcile against — parking them would mean never importing them, which
// fails the actual requirement (import, tag, exclude from budgets).
// Treated as credits so the order is recorded and tagged. Safe because the
// family tag removes it from every budget regardless of instrument.
payment.ambiguous = false;
payment.credits_amount = totals.total_charged;
flags.push("family_payment_assumed_credits");
// LKR 3,783.20"). An earlier version read that as credits-funded. It is
// not: the card statement carries all four of them (CBA ...3893, exact
// foreign_currency_amount matches), so creating a transaction duplicated
// spend that was already recorded — precisely the double-count I5 exists to
// prevent.
//
// Treated as card-settled: provenance only, no transaction. The statement
// line IS the transaction, and it is what should carry the `family` tag.
flags.push("family_card_settled_no_transaction");
} else if (payment.ambiguous) {
// Split not stated and resolvable from the card statement — left for the
// ingestion runner to reconcile, not guessed here.