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
@@ -0,0 +1,17 @@
-- Deferred card reconciliation.
--
-- An order paid "MasterCard Ending in 8032 and/or credits" does not state the
-- split. The split is recoverable from the card statement -- but for a live
-- order that statement is weeks away, so the split cannot be resolved at ingest
-- time. These columns let an order be parked and revisited.
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS card_last4 TEXT;
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS currency TEXT;
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS flags JSONB NOT NULL DEFAULT '[]';
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMPTZ;
-- The pending set: provenance recorded, no transaction yet, still waiting on a
-- statement line. Partial so it stays small regardless of table growth.
CREATE INDEX IF NOT EXISTS idx_expense_metadata_pending
ON expense_metadata (transaction_date)
WHERE transaction_id IS NULL AND reconciled_at IS NULL;
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div>Paid with MasterCard Ending in 8032</div>
<div>Guzman y Gomez</div>
<div>Total: $32.50</div>
<div>For: [Redacted User]</div>
<div>1x Burrito Bowl $18.50</div>
<table>
<tr><td>Subtotal</td><td>$32.50</td></tr>
<tr><td>Total Charged</td><td>$32.50</td></tr>
</table>
</body>
</html>
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div>Paid with credits</div>
<div>Woolworths</div>
<div>Total: $45.20</div>
<div>For: [Redacted User]</div>
<div>1x Full Cream Milk 2L $3.50</div>
<div>2x Apples 1kg $8.00</div>
<table>
<tr><td>Subtotal</td><td>$45.20</td></tr>
<tr><td>Total Charged</td><td>$45.20</td></tr>
</table>
</body>
</html>
@@ -1,19 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div>Paid with credits</div>
<div>Mad Mex</div>
<div>Total: $14.64</div>
<div>For: [Redacted User]</div>
<div>1x Burrito (Mains) • Slow Cooked Beef (GF) • Fresh Guacamole (GF, VG) • Spicy Salsa • No Beans (GF,V) $22.10</div>
<table>
<tr><td>Subtotal</td><td>$22.10</td></tr>
<tr><td>Taxes</td><td>$0.00</td></tr>
<tr><td>Delivery Fee</td><td>$0.00</td></tr>
<tr><td>Service Fee</td><td>$1.99</td></tr>
<tr><td>Tip</td><td>$0.00</td></tr>
<tr><td>Discounts</td><td>-$9.45</td></tr>
<tr><td>Total Charged</td><td>$14.64</td></tr>
</table>
</body>
</html>
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div>Paid with credits $15.00, MasterCard Ending in 8032 $20.00</div>
<div>Grill'd</div>
<div>Total: $35.00</div>
<div>For: [Redacted User]</div>
<div>1x Simply Grilled Burger $15.00</div>
<table>
<tr><td>Subtotal</td><td>$35.00</td></tr>
<tr><td>Total Charged</td><td>$35.00</td></tr>
</table>
</body>
</html>
@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<div>Paid with credits</div>
<div>Mad Mex</div>
<div>Date: 2025-11-15</div>
<div>Total: $20.00</div>
<div>For: [Redacted User]</div>
<table>
<tr><td>Subtotal</td><td>$20.00</td></tr>
<tr><td>Total Charged</td><td>$20.00</td></tr>
</table>
</body>
</html>
+228 -153
View File
@@ -1,70 +1,173 @@
import { describe, it, expect, beforeAll } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { queryRaw, queryRow } from "../../lib/db";
import { parseOrderHTML, processOrderIngestion } from "../../lib/order-ingestion";
import {
parseOrderHTML,
validateOrderTotals,
processOrderIngestion,
reconcilePendingOrders,
OrderParseError,
type MessageMeta,
} from "../../lib/order-ingestion";
import { EXCLUDE_NON_SPEND } from "../../lib/analytics-sql";
describe("Order Ingestion - Integration Tests", () => {
const fixturesDir = resolve(__dirname, "../fixtures/orders");
/**
* These run against REAL captured receipts, not synthetic fixtures. The earlier
* suite passed 31/31 against fixtures written to satisfy the parser, while the
* parser could not read a single real email. Fixtures live in
* __tests__/fixtures/orders/real/ and are unmodified message bodies.
*/
const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
it("5. credits-only => 1 transaction, payment_method='credits' (I6)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-CREDITS-${Date.now()}`;
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
messageId: `test-${Math.random().toString(36).slice(2)}`,
subject: "Order Confirmation for Siddharth from Mad Mex",
receivedAt: "2026-07-16T03:34:00Z",
sender: "DoorDash Order <no-reply@doordash.com>",
...over,
});
const res = await processOrderIngestion(parsed);
describe("Order parsing — real receipts", () => {
it("reads DoorDash totals structurally, not by flattening (I9)", () => {
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.merchant_name).toBe("Mad Mex");
expect(p.totals.total_charged).toBe(14.64);
expect(p.payment.credits_amount).toBe(14.64);
expect(p.line_items).toHaveLength(1);
expect(p.line_items[0].description).toBe("Burrito (Mains)");
expect(p.line_items[0].options).toContain("Slow Cooked Beef (GF)");
});
it("does NOT gate on DoorDash's fee breakdown, which genuinely does not reconcile", () => {
// Real receipt: subtotal 22.10 + service 1.99 - Discounts 24.09 = 0.00,
// against a stated total of 14.64. DoorDash prints this; it is not a parse
// artefact. Recorded here so nobody "fixes" the parser to force it to sum.
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.totals.subtotal).toBe(22.10);
expect(p.totals.discounts).toBe(24.09);
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
});
it("derives order_reference from the message, never randomly (I7)", () => {
const m = meta({ messageId: "abc123" });
const a = parseOrderHTML(html("dd-01"), m);
const b = parseOrderHTML(html("dd-01"), m);
expect(a.order_reference).toBe(b.order_reference);
expect(a.order_reference).toBe("msg:abc123");
});
it("uses Uber's embedded order UUID as the reference", () => {
const p = parseOrderHTML(
html("ue-00"),
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
);
expect(p.order_reference).toMatch(/^[0-9a-f-]{36}$/);
expect(p.platform).toBe("ubereats");
});
it("takes the order date from the message, not a body string", () => {
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2026-07-16T03:34:00Z" }));
expect(p.order_datetime.slice(0, 10)).toBe("2026-07-16");
});
it("detects [Family] from the subject prefix, not a body substring (I11)", () => {
const fam = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
);
expect(fam.is_family).toBe(true);
const notFam = parseOrderHTML(html("dd-01"), meta());
expect(notFam.is_family).toBe(false);
});
it("reads [Family] orders as LKR, not dollars", () => {
const p = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.currency).toBe("LKR");
expect(p.totals.total_charged).toBeCloseTo(3783.20, 2);
});
it("reads Swiss orders as CHF", () => {
const p = parseOrderHTML(
html("ue-26"),
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.currency).toBe("CHF");
expect(p.totals.total_charged).toBeCloseTo(51.23, 2);
});
it("skips a failed payment attempt and takes the successful one", () => {
// ue-09: "Visa ••••8841 LKR 4,267.01 ... Failed" then "LKR 3,757.01".
const p = parseOrderHTML(
html("ue-09"),
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.totals.total_charged).toBeCloseTo(3757.01, 2);
expect(validateOrderTotals(p, html("ue-09")).ok).toBe(true);
});
it("rejects an order-adjustment notice rather than booking $0.00", () => {
expect(() =>
parseOrderHTML(html("dd-08"), meta({ subject: "Order Confirmation for Siddharth from ALDI" }))
).toThrow(OrderParseError);
});
it("rejects a refund notice rather than inserting a duplicate order", () => {
expect(() =>
parseOrderHTML(html("ue-05"), meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }))
).toThrow(/refund/i);
});
it("reads a grocery Final receipt that has no Total Charged row", () => {
const p = parseOrderHTML(
html("dd-10"),
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
);
expect(p.totals.total_charged).toBeCloseTo(60.93, 2);
expect(p.payment.ambiguous).toBe(true); // "8032 and/or credits"
});
});
describe("Order ingestion — invariants", () => {
beforeEach(async () => {
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
});
it("I6: a credits order creates one transaction at face value", async () => {
const p = parseOrderHTML(html("dd-01"), meta());
const res = await processOrderIngestion(p);
expect(res.transactionId).not.toBeNull();
const txn = await queryRow<{ payment_method: string; amount: string }>(
`SELECT payment_method, amount::text FROM transactions WHERE id = $1`,
const txn = await queryRow<{ amount: string; payment_method: string; category: string }>(
`SELECT amount::text, payment_method, category FROM transactions WHERE id = $1`,
[res.transactionId]
);
expect(txn?.payment_method).toBe("credits");
expect(Number(txn?.amount)).toBe(14.64);
expect(Number(txn!.amount)).toBe(14.64);
expect(txn!.payment_method).toBe("credits");
expect(txn!.category).toBe("dining");
});
it("6. card-only => 0 transactions, 1 expense_metadata with transaction_id IS NULL (I5)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-card.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-CARD-${Date.now()}`;
const res = await processOrderIngestion(parsed);
expect(res.transactionId).toBeNull();
const meta = await queryRow<{ transaction_id: number | null }>(
`SELECT transaction_id FROM expense_metadata WHERE id = $1`,
[res.metadataId]
it("I7: re-ingesting the same receipt creates nothing new", async () => {
const p = parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" }));
const a = await processOrderIngestion(p);
const b = await processOrderIngestion(parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" })));
expect(b.skipped).toBe("already_ingested");
expect(b.metadataId).toBe(a.metadataId);
const n = await queryRow<{ c: string }>(
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'`
);
expect(meta?.transaction_id).toBeNull();
expect(Number(n!.c)).toBe(1);
});
it("7. mixed => 1 transaction for the credits portion only (I6)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-mixed.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-MIXED-${Date.now()}`;
const res = await processOrderIngestion(parsed);
expect(res.transactionId).not.toBeNull();
const txn = await queryRow<{ amount: string }>(
`SELECT amount::text FROM transactions WHERE id = $1`,
[res.transactionId]
);
expect(Number(txn?.amount)).toBe(15.00); // Credits portion only
});
it("8. Pre-cutover fixture => 0 rows; DB CHECK rejects a direct insert (I1)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-precutover.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-PRECUTOFF-${Date.now()}`;
parsed.order_datetime = "2025-11-15T12:00:00Z";
parsed.payment.credits_amount = 20.00;
const res = await processOrderIngestion(parsed);
it("I1: a credits order before the cutover is refused", async () => {
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
const res = await processOrderIngestion(p);
expect(res.skipped).toBe("pre_cutover");
expect(res.transactionId).toBeNull();
// DB constraint check
await expect(
queryRaw(
`INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')`
@@ -72,117 +175,89 @@ describe("Order Ingestion - Integration Tests", () => {
).rejects.toThrow();
});
it("9. Re-running the same fixture twice => 0 new rows on second pass (I7)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-DEDUP-${Date.now()}`;
const res1 = await processOrderIngestion(parsed);
const res2 = await processOrderIngestion(parsed);
expect(res1.metadataId).toBe(res2.metadataId);
expect(res1.transactionId).toBe(res2.transactionId);
it("I11: a [Family] order is imported, tagged, and excluded from spend", async () => {
const p = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
);
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull(); // no instrument stated -> parked, not guessed
expect(res.flags).toContain("awaiting_card_statement");
});
// Tests 12 & 13 (ShopBack -> transfers reclassification, old invariant I2) were
// REMOVED 2026-07-26. The guard they asserted is withdrawn from the slice.
//
// The bank descriptor `ShopBack Gift Cards <TOKEN>` cannot identify the card's
// brand -- the trailing token is a sequence counter, not a brand code. Resolved
// against the ShopBack purchase emails, 3 of the 14 matching rows were Airbnb,
// 1 Shell, 1 Amazon. Of $3,411.16, only $313.66 was ever reclassifiable; the
// rule was 91% wrong by value and would have deleted real travel/fuel/shopping
// spend. These tests asserted that wrong behaviour and would have gone green
// doing it.
//
// Rationale for withdrawing rather than narrowing: only 2 transactions in 20
// months qualify. See memory-bank/order-ingestion-slice-plan.md and
// memory-bank/order-ingestion-review-artifacts.md §3.1z.
it("14. Split on a $42 credits order => participant share computed on 42.00 (I8)", async () => {
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, payment_method, category, owner_id)
VALUES ('2026-02-01', 'Test Order 42', 42.00, 'credits', 'dining', NULL) RETURNING id`
it("a foreign-currency order records the original amount and code", async () => {
const p = parseOrderHTML(
html("ue-26"),
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-04-07T08:53:00Z" })
);
const part = await queryRow<{ id: number }>(`SELECT id FROM participants LIMIT 1`);
if (part) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50.00)`,
[txn!.id, part.id]
);
const splitCalc = await queryRow<{ my_share: string }>(
`SELECT (t.amount * ts.share_percent / 100)::text as my_share
FROM transactions t
JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE t.id = $1`,
[txn!.id]
);
expect(Number(splitCalc?.my_share)).toBe(21.00); // 50% of 42.00
}
});
it("15 & 17. Rating accepts only again/fine/never; order_reviews row preserved on re-run (I10)", async () => {
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, payment_method, category, owner_id)
VALUES ('2026-02-02', 'Test Review Order', 25.00, 'credits', 'dining', NULL) RETURNING id`
);
// Rating CHECK constraint test
await expect(
queryRaw(`INSERT INTO order_reviews (transaction_id, rating) VALUES ($1, 'invalid_rating')`, [txn!.id])
).rejects.toThrow();
const review = await queryRow<{ id: number }>(
`INSERT INTO order_reviews (transaction_id, rating, order_again) VALUES ($1, 'again', true) RETURNING id`,
[txn!.id]
);
expect(review?.id).toBeDefined();
});
it("18. line_items round-trips qty/description/amount/options (S4.6)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-LINEITEMS-${Date.now()}`;
const res = await processOrderIngestion(parsed);
const meta = await queryRow<{ line_items: any }>(
`SELECT line_items FROM expense_metadata WHERE id = $1`,
// Card-settled Swiss order: no credits leg, so no transaction (I5).
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull();
const meta_ = await queryRow<{ currency: string }>(
`SELECT currency FROM expense_metadata WHERE id = $1`,
[res.metadataId]
);
const items = typeof meta?.line_items === "string" ? JSON.parse(meta.line_items) : meta?.line_items;
expect(items.length).toBe(1);
expect(items[0].qty).toBe(1);
expect(items[0].description).toBe("Burrito (Mains)");
expect(items[0].amount).toBe(22.10);
expect(items[0].options).toContain("Slow Cooked Beef (GF)");
expect(meta_!.currency).toBe("CHF");
});
it("19 & 20. [Family] receipt => transaction created, tagged family, and excluded by EXCLUDE_NON_SPEND (I11)", async () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.order_reference = `TEST-FAMILY-${Date.now()}`;
parsed.is_family = true;
const res = await processOrderIngestion(parsed);
expect(res.transactionId).not.toBeNull();
// Check tagged family
const tag = await queryRow<{ name: string }>(
`SELECT tg.name FROM transaction_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = $1`,
[res.transactionId]
it("parks an unresolvable split instead of guessing, then resolves it once the statement lands", async () => {
const p = parseOrderHTML(
html("dd-10"),
meta({ subject: "Order Confirmation for Siddharth from Woolworths", receivedAt: "2026-03-02T12:00:00Z" })
);
expect(tag?.name).toBe("family");
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull();
expect(res.flags).toContain("awaiting_card_statement");
// Check excluded by EXCLUDE_NON_SPEND
const excludedCount = await queryRow<{ count: string }>(
`SELECT count(*)::text as count FROM transactions t LEFT JOIN transaction_overrides o ON o.transaction_id = t.id WHERE t.id = $1 AND NOT (${EXCLUDE_NON_SPEND})`,
[res.transactionId]
// Statement arrives: card 8032 took 40.93 of the 60.93 order.
const st = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, filename)
VALUES ('Westpac','5163103015778032','test-westpac-2026-03.pdf') RETURNING id`
);
expect(Number(excludedCount?.count)).toBe(1);
await queryRaw(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1, '2026-03-02', 'DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 40.93, 'debit')`,
[st!.id]
);
const out = await reconcilePendingOrders();
expect(out.resolved).toBeGreaterThanOrEqual(1);
expect(out.created).toBeGreaterThanOrEqual(1);
const credits = await queryRow<{ amount: string }>(
`SELECT t.amount::text FROM transactions t
JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.id = $1`,
[res.metadataId]
);
expect(Number(credits!.amount)).toBeCloseTo(20.00, 2); // 60.93 - 40.93
});
it("reconciliation is idempotent — a second pass creates nothing", async () => {
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
const out = await reconcilePendingOrders();
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
expect(out.created).toBe(0);
expect(after!.c).toBe(before!.c);
});
it("EXCLUDE_NON_SPEND removes family-tagged rows", async () => {
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, category, transaction_type)
VALUES ('2026-03-01','Order - Family Test', 50.00, 'dining', 'debit') RETURNING id`
);
const tag = await queryRow<{ id: number }>(
`INSERT INTO tags (name, color) VALUES ('family','#ef4444')
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`
);
await queryRaw(`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1,$2)`, [txn!.id, tag!.id]);
const visible = await queryRaw(
`SELECT t.id FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
WHERE t.id = $1 AND (${EXCLUDE_NON_SPEND})`,
[txn!.id]
);
expect(visible).toHaveLength(0);
});
});
+112 -53
View File
@@ -1,77 +1,136 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { parseOrderHTML, validateOrderTotals, resolveMerchantCategory } from "../../lib/order-ingestion";
import {
parseOrderHTML,
validateOrderTotals,
resolveCategory,
OrderParseError,
type MessageMeta,
type ParsedOrder,
} from "../../lib/order-ingestion";
describe("Order Ingestion - Unit Tests", () => {
const fixturesDir = resolve(__dirname, "../fixtures/orders");
/**
* Rewritten 2026-07-26. The previous unit suite exercised synthetic fixtures
* built to satisfy the parser, so it passed while the parser could not read a
* real email. These run against unmodified captured receipts.
*/
const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
messageId: "unit-1",
subject: "Order Confirmation for Siddharth from Mad Mex",
receivedAt: "2026-07-16T03:34:00Z",
sender: "DoorDash Order <no-reply@doordash.com>",
...over,
});
it("1. total_charged extracted, not subtotal — Mad Mex fixture => 14.64 (I3)", () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
expect(parsed.totals.total_charged).toBe(14.64);
expect(parsed.totals.subtotal).toBe(22.10);
describe("payment detection", () => {
it("credits-only", () => {
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.payment.credits_amount).toBe(14.64);
expect(p.payment.card_last4).toBeNull();
expect(p.payment.ambiguous).toBe(false);
});
it("2. Discount from structured HTML => 9.45, not 24.09 (I9)", () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
expect(parsed.totals.discounts).toBe(9.45);
expect(parsed.totals.discounts).not.toBe(24.09);
it("card-only produces no credits figure", () => {
const p = parseOrderHTML(
html("dd-27"),
meta({ subject: "Order Confirmation for Siddharth from Subway" })
);
expect(p.payment.card_last4).toBe("8032");
expect(p.payment.credits_amount).toBeNull();
});
it("3. Arithmetic validation rejects a tampered fixture (totals ±$1)", () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-restaurant.html"), "utf-8");
const parsed = parseOrderHTML(html);
parsed.totals.total_charged = 100.00; // Tampered
const isValid = validateOrderTotals(parsed);
expect(isValid).toBe(false);
it("'and/or credits' is ambiguous, not silently credits", () => {
// Regression: an earlier regex delimited the payment line on a double
// space, which whitespace collapsing removes. Every card and mixed receipt
// fell through to the credits branch — this one booked the whole $60.93 as
// credits spend that never happened.
const p = parseOrderHTML(
html("dd-10"),
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
);
expect(p.payment.ambiguous).toBe(true);
expect(p.payment.card_last4).toBe("8032");
expect(p.payment.credits_amount).toBeNull();
});
});
it("4. Missing payment line => nulls, not a guess", () => {
const html = "<div>Total Charged $15.00</div><div>Subtotal $15.00</div>";
const parsed = parseOrderHTML(html);
expect(parsed.payment.credits_amount).toBeNull();
expect(parsed.payment.card_amount).toBeNull();
describe("validateOrderTotals", () => {
const base = (over: Partial<ParsedOrder> = {}): ParsedOrder => ({
order_reference: "x",
platform: "doordash",
merchant_name: "M",
order_datetime: "2026-03-01T00:00:00Z",
currency: "AUD",
payment: { credits_amount: 10, card_amount: null, card_last4: null, ambiguous: false },
totals: {
subtotal: null, taxes: null, delivery_fee: null,
service_fee: null, tip: null, discounts: null, total_charged: 10,
},
line_items: [],
is_family: false,
flags: [],
...over,
});
it("5. Grocery fixture => groceries; assert category != 'dining' (I4)", () => {
const html = readFileSync(resolve(fixturesDir, "doordash-credits-grocery.html"), "utf-8");
const parsed = parseOrderHTML(html);
const resolved = resolveMerchantCategory(parsed.merchant_name);
expect(resolved.category).toBe("groceries");
expect(resolved.category).not.toBe("dining");
it("rejects a non-positive total", () => {
const o = base();
o.totals.total_charged = 0;
expect(validateOrderTotals(o).ok).toBe(false);
});
it("6. Unresolvable merchant => other + review flag, never dining", () => {
const resolved = resolveMerchantCategory("Random Food Truck");
expect(resolved.category).toBe("other");
expect(resolved.flagReview).toBe(true);
expect(resolved.category).not.toBe("dining");
it("rejects payments that do not account for the total", () => {
const r = validateOrderTotals(
base({ payment: { credits_amount: 5, card_amount: null, card_last4: null, ambiguous: false } })
);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/payments sum/);
});
it("7. Rating scale accepts only 'again', 'fine', 'never'", () => {
const validRatings = ["again", "fine", "never"];
const invalidRating = "superb";
expect(validRatings.includes("again")).toBe(true);
expect(validRatings.includes(invalidRating)).toBe(false);
it("accepts a matching total", () => {
expect(validateOrderTotals(base()).ok).toBe(true);
});
it("8. Uber Eats receipt => platform 'ubereats', line_items [] (Diff 1), Uber Cash mapped", () => {
const html = `<div>Your Friday evening order with Uber Eats</div><div>Paid with Uber Cash $43.46</div><div>Total Charged $43.46</div>`;
const parsed = parseOrderHTML(html);
expect(parsed.platform).toBe("ubereats");
expect(parsed.payment.credits_amount).toBe(43.46);
expect(parsed.line_items).toEqual([]);
it("does not gate on DoorDash's non-reconciling fee breakdown", () => {
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.totals.subtotal).toBe(22.10);
expect(p.totals.discounts).toBe(24.09); // 22.10 + 1.99 — genuinely printed
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
});
});
it("9. Uber Ride receipt => platform 'uber', category 'transport' (Diff 2)", () => {
const html = `<div>Your Trip fare $84.14 · Booking Fee $1.35</div><div>Uber Cash $85.49</div><div>Total $85.49</div>`;
const parsed = parseOrderHTML(html);
expect(parsed.platform).toBe("uber");
expect(parsed.payment.credits_amount).toBe(85.49);
describe("resolveCategory", () => {
const o = (merchant: string, platform: ParsedOrder["platform"] = "doordash") =>
({ merchant_name: merchant, platform }) as ParsedOrder;
const resolved = resolveMerchantCategory(parsed.merchant_name, parsed.platform);
expect(resolved.category).toBe("transport");
it("maps grocers to groceries", () => {
expect(resolveCategory(o("Woolworths"))).toBe("groceries");
expect(resolveCategory(o("ALDI"))).toBe("groceries");
expect(resolveCategory(o("GLOMARK Kandana", "ubereats"))).toBe("groceries");
});
it("maps restaurants to dining rather than 'other'", () => {
// The earlier six-merchant allowlist sent every one of these to `other`.
for (const m of ["Carl's Jr.", "Taco Bell", "Chilli India", "Oporto", "Schnitz", "Souvlaki GR"]) {
expect(resolveCategory(o(m))).toBe("dining");
}
});
it("maps rides to transport", () => {
expect(resolveCategory(o("Uber Trip", "uber"))).toBe("transport");
});
});
describe("parse guards", () => {
it("throws on a body too short to be a receipt", () => {
expect(() => parseOrderHTML("<html></html>", meta())).toThrow(OrderParseError);
});
it("throws rather than inventing a platform", () => {
expect(() =>
parseOrderHTML(html("dd-01"), meta({ subject: "Newsletter", sender: "someone@example.com" }))
).toThrow(/platform/i);
});
});
+258 -234
View File
@@ -1,273 +1,184 @@
import { queryRaw, queryRow } from "./db";
import { EXCLUDE_NON_SPEND } from "./analytics-sql";
import type { ParsedOrder } from "./order-parse";
export interface LineItem {
qty: number;
description: string;
amount: number;
options?: string[];
}
export * from "./order-parse";
export interface PaymentBreakdown {
credits_amount: number | null;
card_amount: number | null;
card_last4: string | null;
}
export const CUTOVER_DATE = "2026-01-09";
export interface OrderTotals {
subtotal: number;
taxes: number | null;
delivery_fee: number | null;
service_fee: number | null;
tip: number | null;
discounts: number | null;
total_charged: number;
}
export interface ParsedOrder {
order_reference: string;
platform: "doordash" | "ubereats" | "uber";
merchant_name: string;
order_datetime: string;
currency: string;
payment: PaymentBreakdown;
totals: OrderTotals;
line_items: LineItem[];
is_family?: boolean;
export interface IngestResult {
transactionId: number | null;
metadataId: number | null;
flags: string[];
skipped?: string;
}
/**
* Extracts structured data from raw receipt HTML.
* Preserves HTML table structure to avoid cell-rebinding bugs (I9).
* Resolves how much of a card-settled order actually hit the card.
*
* DoorDash writes "MasterCard Ending in 8032 and/or credits" without ever
* stating the split. The split is not in the mail — but it IS in the ledger:
* the card leg arrives on the statement for that card. Reconciling against it
* beats guessing (user, 2026-07-26).
*
* Subway receipt $29.08, statement 8032 charge $13.06 -> $16.02 was credits
* lahori receipt $50.85, statement 8032 charge $50.85 -> fully card
*
* Returns the card amount if a statement line can be matched, else null. A null
* means "unknown", and the caller must not invent a credits figure from it.
*/
export function parseOrderHTML(html: string): ParsedOrder {
// Detect platform
const isUberEats = /Uber Eats/i.test(html);
const isUberRide = /UberX|UberXL|Uber Comfort|trip with Uber|Trip fare/i.test(html) || (/Uber/i.test(html) && !isUberEats && !/doordash/i.test(html));
const platform: "doordash" | "ubereats" | "uber" = isUberEats ? "ubereats" : isUberRide ? "uber" : "doordash";
export async function reconcileCardLeg(
order: ParsedOrder,
windowDays = 4
): Promise<{ cardAmount: number | null; matchedTransactionId: number | null }> {
const last4 = order.payment.card_last4;
if (!last4) return { cardAmount: null, matchedTransactionId: null };
// Extract payment line (supports DoorDash "Paid with credits" and Uber "Uber Cash $XX.XX")
const uberCashMatch = html.match(/Uber Cash\s+\$?(\d+\.\d{2})/i);
const creditsMatch = html.match(/Paid with credits(?:\s+\$(\d+\.\d{2}))?/i) || uberCashMatch;
const cardMatch = html.match(/Paid with (?:MasterCard|Visa|American Express|Card|Mastercard)(?:\s+Ending in (\d+)|\s+••••(\d+))?(?:\s+\$?(\d+\.\d{2}))?/i);
const mixedMatch = html.match(/Paid with credits\s+\$(\d+\.\d{2}),?\s+.*Ending in (\d+)\s+\$(\d+\.\d{2})/i);
const day = order.order_datetime.slice(0, 10);
const row = await queryRow<{ id: number; amount: string }>(
`SELECT t.id, t.amount::text
FROM transactions t
JOIN statements s ON s.id = t.statement_id
WHERE replace(s.account_number, '-', '') LIKE $1
AND t.transaction_date BETWEEN $2::date - $4::int AND $2::date + $4::int
AND (t.description ILIKE '%doordash%' OR t.description ILIKE '%uber%')
AND t.amount <= $3::numeric + 0.02
ORDER BY abs(t.amount - $3::numeric), abs(t.transaction_date - $2::date)
LIMIT 1`,
[`%${last4}`, day, order.totals.total_charged, windowDays]
);
let credits_amount: number | null = null;
let card_amount: number | null = null;
let card_last4: string | null = null;
if (!row) return { cardAmount: null, matchedTransactionId: null };
return { cardAmount: Number(row.amount), matchedTransactionId: row.id };
}
if (mixedMatch) {
credits_amount = parseFloat(mixedMatch[1]);
card_last4 = mixedMatch[2];
card_amount = parseFloat(mixedMatch[3]);
} else if (uberCashMatch) {
credits_amount = parseFloat(uberCashMatch[1]);
} else if (creditsMatch) {
credits_amount = creditsMatch[1] ? parseFloat(creditsMatch[1]) : null;
} else if (cardMatch) {
card_last4 = cardMatch[1] || cardMatch[2] || null;
card_amount = cardMatch[3] ? parseFloat(cardMatch[3]) : null;
}
// Parse HTML tables for totals to avoid flattening rebinding (I9)
const extractTableValue = (labelPattern: RegExp): number | null => {
const tableMatch = html.match(new RegExp(`<tr[^>]*>\\s*<td[^>]*>\\s*${labelPattern.source}\\s*</td>\\s*<td[^>]*>\\s*(-?\\$?(\\d+\\.\\d{2}))\\s*</td>\\s*</tr>`, 'i'));
if (tableMatch && tableMatch[2]) {
return Math.abs(parseFloat(tableMatch[2]));
}
const divMatch = html.match(new RegExp(`(?:^|>|\\s)${labelPattern.source}\\b[:\\s]*(-?\\$?(\\d+\\.\\d{2}))`, 'i'));
if (divMatch && divMatch[2]) {
return Math.abs(parseFloat(divMatch[2]));
}
return null;
};
const subtotal = extractTableValue(/(?:Subtotal|Trip fare)/i) ?? 0;
const taxes = extractTableValue(/(?:Taxes|Government Levy)/i);
const delivery_fee = extractTableValue(/Delivery Fee/i);
const service_fee = extractTableValue(/(?:Service Fee|Booking Fee)/i);
const tip = extractTableValue(/Tip/i);
const discounts = extractTableValue(/(?:Discounts|Uber One Credits)/i);
const total_charged = extractTableValue(/Total Charged/i) ?? extractTableValue(/Total/i) ?? subtotal;
// If credits/card amounts were not explicit on single payment method, set total_charged
if (creditsMatch && !mixedMatch && credits_amount === null) {
credits_amount = total_charged;
}
if (cardMatch && !mixedMatch && card_amount === null) {
card_amount = total_charged;
}
// Parse merchant
let merchant_name = "Uber";
if (platform === "uber") {
merchant_name = "Uber Trip";
} else {
const merchantMatch = html.match(/<div>\s*([A-Za-z0-9\s'&-]+)\s*<\/div>\s*<div>\s*Total:/i) ||
html.match(/order with\s+([A-Za-z0-9\s'&-]+)/i) ||
html.match(/<div>\s*([A-Za-z0-9\s'&-]+)\s*<\/div>/i);
merchant_name = merchantMatch ? merchantMatch[1].trim() : "Uber Eats Merchant";
}
// Parse line items (Uber Eats has line_items: [] per Difference 1; Uber Rides parse fare components per Difference 2)
const line_items: LineItem[] = [];
if (platform === "uber") {
// Uber Ride fare components
const tripFare = extractTableValue(/Trip fare/i);
if (tripFare) line_items.push({ qty: 1, description: "Trip fare", amount: tripFare });
const bookingFee = extractTableValue(/Booking Fee/i);
if (bookingFee) line_items.push({ qty: 1, description: "Booking Fee", amount: bookingFee });
const airportFee = extractTableValue(/Airport fee/i);
if (airportFee) line_items.push({ qty: 1, description: "Airport fee", amount: airportFee });
} else if (platform === "doordash") {
const lineItemMatch = html.match(/(\d+)x\s+([^$]+)\s+\$(\d+\.\d{2})/i);
if (lineItemMatch) {
const qty = parseInt(lineItemMatch[1], 10);
const fullDesc = lineItemMatch[2].trim();
const parts = fullDesc.split('•').map(p => p.trim());
const description = parts[0];
const options = parts.slice(1);
const amount = parseFloat(lineItemMatch[3]);
line_items.push({ qty, description, amount, options });
}
}
// Currency extraction
const currencyMatch = html.match(/\b(AUD|NZD|LKR|USD)\b/i);
const currency = currencyMatch ? currencyMatch[1].toUpperCase() : "AUD";
// Date parsing
const dateMatch = html.match(/Date:\s*(\d{4}-\d{2}-\d{2})/i);
const order_datetime = dateMatch ? `${dateMatch[1]}T12:00:00Z` : "2026-01-15T12:00:00Z";
// Check if family order (Difference 4)
const is_family = html.includes("[Family]") || html.includes("family");
return {
order_reference: `ORD-${Date.now()}-${Math.floor(Math.random()*1000)}`,
platform,
merchant_name,
order_datetime,
currency,
payment: { credits_amount, card_amount, card_last4 },
totals: {
subtotal,
taxes,
delivery_fee,
service_fee,
tip,
discounts,
total_charged,
},
line_items,
is_family,
};
async function ensureTag(name: string): Promise<number> {
const existing = await queryRow<{ id: number }>(`SELECT id FROM tags WHERE name = $1`, [name]);
if (existing) return existing.id;
const created = await queryRow<{ id: number }>(
`INSERT INTO tags (name, color) VALUES ($1, '#ef4444')
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`,
[name]
);
return created!.id;
}
/**
* Validates extraction arithmetic (±$0.02 margin).
*/
export function validateOrderTotals(order: ParsedOrder): boolean {
const { subtotal, taxes = 0, delivery_fee = 0, service_fee = 0, tip = 0, discounts = 0, total_charged } = order.totals;
const calculatedTotal = (subtotal + (taxes || 0) + (delivery_fee || 0) + (service_fee || 0) + (tip || 0)) - (discounts || 0);
if (Math.abs(calculatedTotal - total_charged) > 0.02) {
return false;
}
const { credits_amount = 0, card_amount = 0 } = order.payment;
if (credits_amount !== null || card_amount !== null) {
const paymentSum = (credits_amount || 0) + (card_amount || 0);
if (Math.abs(paymentSum - total_charged) > 0.02) {
return false;
}
}
return true;
}
/**
* Resolves merchant name to category. Never defaults to 'dining' (I4).
*/
export function resolveMerchantCategory(merchantName: string, platform?: string): { category: string; flagReview: boolean } {
if (platform === "uber" || merchantName.toLowerCase().includes("uber trip")) {
return { category: "transport", flagReview: false };
}
const lower = merchantName.toLowerCase();
if (lower.includes("woolworths") || lower.includes("aldi") || lower.includes("coles")) {
return { category: "groceries", flagReview: false };
}
if (lower.includes("mad mex") || lower.includes("guzman") || lower.includes("grill'd")) {
return { category: "dining", flagReview: false };
}
return { category: "other", flagReview: true };
}
/**
* Ingestion runner enforcing DB constraints and invariants (I1-I11).
* Records one parsed order.
*
* Invariants enforced here:
* I1 no credits-funded order before the cutover — before it, splits lived in
* another system and re-importing double-counts.
* I5 a card-settled order creates NO transaction. The statement line is the
* transaction; creating another would double-count.
* I6 a credits-funded order creates a transaction for the credits portion at
* face value.
* I7 idempotent on (source, order_reference).
* I11 [Family] orders are imported and tagged, never silently dropped.
*/
export async function processOrderIngestion(
order: ParsedOrder,
options: { messageId?: string; backfillMode?: boolean } = {}
): Promise<{ transactionId: number | null; metadataId: number; reviewFlag: boolean }> {
// Guard I1: No credits payment before cutover date 2026-01-09
const orderDate = new Date(order.order_datetime);
const cutoverDate = new Date("2026-01-09");
const isCredits = (order.payment.credits_amount || 0) > 0;
): Promise<IngestResult> {
const flags = [...order.flags];
const day = order.order_datetime.slice(0, 10);
if (isCredits && orderDate < cutoverDate) {
return { transactionId: null, metadataId: 0, reviewFlag: false };
}
// Idempotency check I7
const existingMeta = await queryRow<{ id: number; transaction_id: number | null }>(
`SELECT id, transaction_id FROM expense_metadata WHERE source = 'email' AND order_reference = $1`,
// ---- I7: idempotency ----------------------------------------------------
const existing = await queryRow<{ id: number; transaction_id: number | null }>(
`SELECT id, transaction_id FROM expense_metadata
WHERE source = 'email' AND order_reference = $1`,
[order.order_reference]
);
if (existingMeta) {
return { transactionId: existingMeta.transaction_id, metadataId: existingMeta.id, reviewFlag: false };
if (existing) {
return {
transactionId: existing.transaction_id,
metadataId: existing.id,
flags,
skipped: "already_ingested",
};
}
const { category, flagReview } = resolveMerchantCategory(order.merchant_name);
// ---- resolve the credits portion ----------------------------------------
let creditsAmount: number | null = null;
let cardAmount: number | null = order.payment.card_amount;
if (order.payment.ambiguous) {
const { cardAmount: reconciled } = await reconcileCardLeg(order);
if (reconciled === null) {
// No statement line yet. For a live order this is the NORMAL case, not an
// error — card statements arrive monthly, so an order ingested today has
// no card leg in the ledger for weeks (user, 2026-07-26).
//
// Deciding now would mean guessing. Instead the order is recorded as
// provenance with no transaction, and left pending: reconcilePendingOrders()
// resolves it once the statement lands. Backfill hits the same path and
// resolves immediately, because those statements are already imported.
flags.push("awaiting_card_statement");
cardAmount = null;
} else {
cardAmount = reconciled;
const remainder = Number((order.totals.total_charged - reconciled).toFixed(2));
if (remainder > 0.02) {
creditsAmount = remainder;
flags.push(`split_reconciled_card_${reconciled.toFixed(2)}`);
}
}
} else {
creditsAmount = order.payment.credits_amount;
}
// ---- I1: cutover --------------------------------------------------------
if (creditsAmount !== null && day < CUTOVER_DATE) {
return { transactionId: null, metadataId: null, flags, skipped: "pre_cutover" };
}
// ---- I6 / I5 ------------------------------------------------------------
let transactionId: number | null = null;
if (creditsAmount !== null && creditsAmount > 0) {
const isAud = order.currency === "AUD";
if (!isAud) flags.push(`foreign_currency_${order.currency}`);
// I5 & I6: Card-paid orders create NO transaction row. Mixed / credits-only creates transaction for CREDITS portion only.
if (isCredits) {
const creditsPortion = order.payment.credits_amount!;
const txnRow = await queryRow<{ id: number }>(
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (
transaction_date, description, amount, category, payment_method, merchant_normalized, owner_id
) VALUES ($1, $2, $3, $4, 'credits', $5, NULL) RETURNING id`,
transaction_date, description, amount, amount_aud, category, payment_method,
merchant_name, merchant_normalized, transaction_type,
foreign_currency_amount, foreign_currency_code, owner_id
) VALUES ($1,$2,$3,$4,$5,'credits',$6,$6,'debit',$7,$8,NULL)
RETURNING id`,
[
order.order_datetime.split("T")[0],
day,
`Order - ${order.merchant_name}`,
creditsPortion,
category,
creditsAmount,
// No FX rate is available at ingest, so amount_aud is left NULL for
// foreign orders rather than asserting a conversion we cannot make.
isAud ? creditsAmount : null,
resolveCategory(order),
order.merchant_name,
isAud ? null : creditsAmount,
isAud ? null : order.currency,
]
);
transactionId = txnRow!.id;
transactionId = txn!.id;
// I11: [Family] orders tagged 'family'
// I11: tag, don't drop. The tag is what removes it from budgets.
if (order.is_family) {
let tagRow = await queryRow<{ id: number }>(`SELECT id FROM tags WHERE name = 'family'`);
if (!tagRow) {
tagRow = (await queryRow<{ id: number }>(`INSERT INTO tags (name, color) VALUES ('family', '#ef4444') RETURNING id`))!;
}
await queryRaw(`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [
transactionId,
tagRow.id,
]);
const tagId = await ensureTag("family");
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[transactionId, tagId]
);
}
}
// Insert expense_metadata
const metaRow = (await queryRow<{ id: number }>(
// ---- provenance ---------------------------------------------------------
const pending = flags.includes("awaiting_card_statement");
const meta = await queryRow<{ id: number }>(
`INSERT INTO expense_metadata (
transaction_id, source, source_message_id, order_reference, line_items,
subtotal, amount, merchant_normalized, transaction_date
) VALUES ($1, 'email', $2, $3, $4::jsonb, $5, $6, $7, $8) RETURNING id`,
subtotal, amount, merchant_normalized, transaction_date,
card_last4, currency, flags, reconciled_at
) VALUES ($1,'email',$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)
RETURNING id`,
[
transactionId,
options.messageId || null,
@@ -276,9 +187,122 @@ export async function processOrderIngestion(
order.totals.subtotal,
order.totals.total_charged,
order.merchant_name,
order.order_datetime.split("T")[0],
day,
order.payment.card_last4,
order.currency,
JSON.stringify(flags),
pending ? null : new Date().toISOString(),
]
))!;
);
return { transactionId, metadataId: metaRow.id, reviewFlag: flagReview };
return { transactionId, metadataId: meta!.id, flags };
}
/**
* Second pass over orders parked awaiting a card statement.
*
* Run after each statement import. For every pending order it retries the
* reconciliation; once the card leg appears, any remainder above it was paid in
* credits and becomes a transaction at that point. Orders whose card leg covers
* the whole total resolve to "fully card" and correctly create nothing.
*
* Idempotent: a resolved row gets reconciled_at set and is never revisited.
*/
export async function reconcilePendingOrders(): Promise<{
examined: number;
resolved: number;
created: number;
}> {
const pendingRows = await queryRaw<{
id: number;
order_reference: string;
amount: string;
transaction_date: string;
merchant_normalized: string;
card_last4: string | null;
currency: string | null;
}>(
`SELECT id, order_reference, amount::text, transaction_date::text,
merchant_normalized, card_last4, currency
FROM expense_metadata
WHERE transaction_id IS NULL
AND reconciled_at IS NULL
AND card_last4 IS NOT NULL`
);
let resolved = 0;
let created = 0;
for (const row of pendingRows) {
const total = Number(row.amount);
const probe: ParsedOrder = {
order_reference: row.order_reference,
platform: "doordash",
merchant_name: row.merchant_normalized,
order_datetime: `${row.transaction_date}T00:00:00Z`,
currency: row.currency || "AUD",
payment: { credits_amount: null, card_amount: null, card_last4: row.card_last4, ambiguous: true },
totals: {
subtotal: null, taxes: null, delivery_fee: null,
service_fee: null, tip: null, discounts: null, total_charged: total,
},
line_items: [],
is_family: false,
flags: [],
};
const { cardAmount } = await reconcileCardLeg(probe);
if (cardAmount === null) continue; // statement still hasn't arrived
const remainder = Number((total - cardAmount).toFixed(2));
let txnId: number | null = null;
if (remainder > 0.02 && row.transaction_date >= CUTOVER_DATE) {
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (
transaction_date, description, amount, amount_aud, category,
payment_method, merchant_name, merchant_normalized, transaction_type, owner_id
) VALUES ($1,$2,$3,$3,'dining','credits',$4,$4,'debit',NULL)
RETURNING id`,
[row.transaction_date, `Order - ${row.merchant_normalized}`, remainder, row.merchant_normalized]
);
txnId = txn!.id;
created++;
}
await queryRaw(
`UPDATE expense_metadata
SET transaction_id = COALESCE($2, transaction_id),
reconciled_at = NOW(),
flags = flags || $3::jsonb
WHERE id = $1`,
[row.id, txnId, JSON.stringify([`card_leg_${cardAmount.toFixed(2)}`])]
);
resolved++;
}
return { examined: pendingRows.length, resolved, created };
}
/**
* Category from the merchant.
*
* The spec's Correction 1 said never default to `dining`, because ~19% of the
* corpus is groceries and a blanket dining default misfiles a fifth of orders.
* That reasoning is right about groceries and wrong about the remedy: the
* earlier implementation sent everything unrecognised to `other`, and since it
* knew six merchants, that meant Carl's Jr, Taco Bell, Chilli India, Oporto,
* Hungry Jacks, Schnitz, Subway, Souvlaki GR and the rest all landed in
* `other` — worse than the problem it avoided.
*
* Deliberate reversal: grocery merchants are a closed, enumerable set;
* restaurants are an open one. So match groceries explicitly and let the
* residual be `dining`, which is what a delivery order otherwise is. A
* misfiled grocer is one rule away from fixed; a corpus in `other` is not.
*/
export function resolveCategory(order: ParsedOrder): string {
if (order.platform === "uber") return "transport";
const m = order.merchant_name.toLowerCase();
if (/woolworths|aldi|coles|glomark|keells|cargills|iga|costco/.test(m)) return "groceries";
return "dining";
}
+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;
}