feat(orders): amendments, family imports, and the ingest API
Closes the three gaps left after the parser rebuild.
Refund amendments. ue-05 is a real refund: 'Previous total $49.94 / Refund
-$4.21 / New Total $45.73'. Uber reuses the order UUID across the receipt and
the amendment, so the two can be matched. The transaction is reduced in place
rather than offset with a second row — the order is one event whose cost
changed, and a compensating row would misreport both the meal count and the
merchant's spend. When the original was never ingested, nothing is invented.
[Family] orders now import instead of parking. Their payment line names 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 — they would have sat
pending forever, which fails the actual requirement to import and tag them.
Treated as credits, flagged as an assumption. Safe because the family tag
removes them from every budget regardless of instrument, and the LKR amount is
preserved with amount_aud left NULL rather than asserting an FX rate.
Ingest API. n8n now POSTs each message to /api/orders/ingest instead of parsing
in a Code node — the n8n sandbox has no require or fs, so a parser there cannot
be tested against the fixture corpus, which is the one thing that makes this
parser trustworthy. Auth is a shared secret, since machine callers have no
Traefik session header. Rejections return 422 and record nothing.
60 unit + 45 integration green on three consecutive runs; 63/65 corpus holds.
This commit is contained in:
@@ -7,6 +7,8 @@ import {
|
||||
validateOrderTotals,
|
||||
processOrderIngestion,
|
||||
reconcilePendingOrders,
|
||||
parseOrderAmendment,
|
||||
applyOrderAmendment,
|
||||
OrderParseError,
|
||||
type MessageMeta,
|
||||
} from "../../lib/order-ingestion";
|
||||
@@ -181,8 +183,8 @@ describe("Order ingestion — invariants", () => {
|
||||
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");
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
expect(res.flags).toContain("family_payment_assumed_credits");
|
||||
});
|
||||
|
||||
it("a foreign-currency order records the original amount and code", async () => {
|
||||
@@ -261,3 +263,92 @@ describe("Order ingestion — invariants", () => {
|
||||
expect(visible).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refund amendments", () => {
|
||||
const ueMeta = (over = {}) => meta({
|
||||
subject: "Your Tuesday evening order with Uber Eats",
|
||||
sender: "uber.com",
|
||||
receivedAt: "2026-07-07T08:17:00Z",
|
||||
...over,
|
||||
});
|
||||
|
||||
it("parses a refund notice into an amendment, not an order", () => {
|
||||
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||
expect(a.previous_total).toBeCloseTo(49.94, 2);
|
||||
expect(a.refund_amount).toBeCloseTo(4.21, 2);
|
||||
expect(a.new_total).toBeCloseTo(45.73, 2);
|
||||
expect(a.order_reference).toMatch(/^[0-9a-f-]{36}$/);
|
||||
});
|
||||
|
||||
it("reduces the original transaction instead of adding a second row", async () => {
|
||||
// Seed the original order this amendment refers to.
|
||||
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||
const txn = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (transaction_date, description, amount, amount_aud, category, payment_method, transaction_type)
|
||||
VALUES ('2026-07-07','Order - Coles (Wyndham Vale)', 49.94, 49.94, 'groceries', 'credits', 'debit')
|
||||
RETURNING id`
|
||||
);
|
||||
await queryRaw(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, amount, transaction_date, flags)
|
||||
VALUES ($1,'email',$2, 49.94, '2026-07-07', '[]'::jsonb)`,
|
||||
[txn!.id, a.order_reference]
|
||||
);
|
||||
|
||||
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||
const res = await applyOrderAmendment(a);
|
||||
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||
|
||||
expect(res.matched).toBe(true);
|
||||
expect(after!.c).toBe(before!.c); // amended in place, no second row
|
||||
const updated = await queryRow<{ amount: string }>(
|
||||
`SELECT amount::text FROM transactions WHERE id = $1`,
|
||||
[txn!.id]
|
||||
);
|
||||
expect(Number(updated!.amount)).toBeCloseTo(45.73, 2);
|
||||
});
|
||||
|
||||
it("invents nothing when the original order was never ingested", async () => {
|
||||
const a = parseOrderAmendment(html("ue-05"), ueMeta());
|
||||
await queryRaw(`DELETE FROM expense_metadata WHERE order_reference = $1`, [a.order_reference]);
|
||||
const res = await applyOrderAmendment(a);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.transactionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("[Family] orders import rather than park", () => {
|
||||
it("records a family order as credits and tags it", 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" })
|
||||
);
|
||||
expect(p.flags).toContain("family_payment_assumed_credits");
|
||||
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
|
||||
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]
|
||||
);
|
||||
expect(tag!.name).toBe("family");
|
||||
|
||||
// LKR is preserved, and amount_aud stays NULL — no FX rate is available.
|
||||
const txn = await queryRow<{ foreign_currency_code: string; amount_aud: string | null }>(
|
||||
`SELECT foreign_currency_code, amount_aud::text FROM transactions WHERE id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(txn!.foreign_currency_code).toBe("LKR");
|
||||
expect(txn!.amount_aud).toBeNull();
|
||||
|
||||
// And it must not reach spend.
|
||||
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})`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(visible).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user