Three faults on the same function, all of them "the write is assumed to have
worked".
- The create path snapshotted the undo baseline AFTER its INSERT. A crash in
between left the retry recording the intake's own write as "previous", so
undo restored the thing it was meant to reverse — the exact failure
previous_line_items exists to prevent. Both paths now snapshot first.
- An UPDATE against a row deleted between the select and the write affects
nothing and still returned a metadata_id, so the batch was marked enriched
against a row nobody had written to. The write is now checked and falls
through to the ordered lookup, then to a create.
- Two calls for one transaction could both find no row and both INSERT;
expense_metadata.transaction_id is UNIQUE, so one lost with a constraint
error that reached the user as an error card. ON CONFLICT (transaction_id)
DO UPDATE, with (xmax = 0) so created_metadata stays truthful.
And the new row's currency comes from the batch instead of a hard-coded 'AUD'.
OrderDetails prefers expense_metadata.currency, so a USD order — booked
correctly on the transaction with foreign_currency_code — printed "$".
Three defects on one surface, all invisible while a transaction has exactly
one expense_metadata row — which is almost always.
1. ORDER BY (transaction_id = $1) DESC did the opposite of its comment.
Postgres orders DESC as NULLS FIRST, and the row that merely POINTS at the
transaction has a NULL transaction_id, so the clause written to prefer the
owned row preferred the other one — in exactly the case it exists for.
matchTransaction already spelled out NULLS LAST; now so do this and
GET /api/transactions/[id]/order, which had no ORDER BY at all and could
read a different row from the one an enrichment had just written.
2. replaceLineItems takes an optional metadataId. The match resolves a row
through a platform-filtered join; re-picking independently here can land
the write on a row the match ruled out.
3. previous_line_items on intake_batches is the undo baseline, recorded once
per batch under COALESCE and read back. Returning whatever was just read
hands a pantry retry its own write as the "previous" state, so undo
restores the thing it undid. Migration 0034 is unapplied anywhere, so the
column is added there rather than in a 0035.
One upload point, every applicable step. Persist the batch, classify each
file, ONE LiteLLM call (cloud-budget, temperature 0, strict json_schema; PDFs
as unpdf text, never pixels) with an order-item prompt that keeps names
verbatim, header (merchant, date, total), merchant pattern
(coles|woolworths|woolies|aldi|iga|foodworks|costco) -> grocery | food | ask,
exact (platform, merchant, date, total) match against transactions +
expense_metadata, then act:
- food + unique match -> replace line_items via the C2 path, 'enriched'
- food + miss/ambiguity -> 'candidates' (same merchant ±3d) with
intake_use_candidate / intake_create buttons; never auto-creates
- grocery -> original files to pantry POST /api/capture with
finance_target_transaction_id + finance_action enrich|create (C3);
409 -> 'duplicate'
- ask / needs_header / held / duplicate / unsupported as replies, not writes
Reconciliation is against the printed item subtotal (a delivery total is
items + fees - promotions: 18.94 of Coles lines settled at 5.74), or
2%, else 'held'. Originals are kept under INTAKE_FILE_DIR by sha256 so a later
'Groceries' tap can still forward them.
C2 is an idempotent replace on transaction_id = :id OR
matched_transaction_id = :id, accepts INTAKE_TOKEN or RECEIPT_INGEST_TOKEN,
creates the expense_metadata row when absent, 404 when the transaction does
not exist, returns {previous_line_items, line_items} so pantry's undo is the
same call with the previous list.