line-items: one metadata row, agreed on by the write and the read

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.
This commit is contained in:
2026-08-21 17:52:47 +10:00
parent 7424d13858
commit f9638cc5e5
3 changed files with 79 additions and 13 deletions
@@ -32,10 +32,20 @@ CREATE TABLE IF NOT EXISTS intake_batches (
status text NOT NULL,
transaction_id integer,
capture_id integer,
-- The undo baseline: what `expense_metadata.line_items` held before this
-- batch first replaced it. Recorded here rather than returned from the write,
-- because pantry re-calls the enrich endpoint on every confirm and after a
-- timeout — a caller that hands back whatever it just read gives a retry its
-- OWN write as the "previous" state, and undo then restores the thing it was
-- meant to reverse. Written under COALESCE, so only the first snapshot sticks.
previous_line_items jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz
);
-- For a database that already ran an earlier draft of this file.
ALTER TABLE intake_batches ADD COLUMN IF NOT EXISTS previous_line_items jsonb;
COMMENT ON TABLE intake_batches IS
'One row per upload batch through POST /api/intake. Persisted before '
'extraction so Slack taps can act on it later and re-shares are detected. '
@@ -34,6 +34,14 @@ export async function GET(
-- through matched_transaction_id. Both directions have to resolve or the
-- detail is missing on exactly the orders that were paid by card.
WHERE transaction_id = $1 OR matched_transaction_id = $1
-- The same order as replaceLineItems in line-items.ts and matchTransaction
-- in intake-router.ts. Both directions can resolve at once — a statement
-- line owning its own row while a delivery receipt points at it — and an
-- unordered LIMIT 1 then reads a different row from the one an enrichment
-- wrote, so a PUT that reported success showed nothing on the screen.
-- NULLS LAST because DESC is NULLS FIRST and the pointing row's
-- transaction_id is NULL.
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
LIMIT 1`,
[Number(id)]
);
+61 -13
View File
@@ -15,6 +15,12 @@ import { queryRow, queryRaw } from "@/lib/db";
* transaction has no row at all (a manual transaction, a statement line), one
* is created so the items have somewhere to live; a transaction is NEVER
* created here.
*
* `opts.metadataId` pins WHICH row when the caller already resolved one. A
* statement transaction can own its own `expense_metadata` row AND be the
* `matched_transaction_id` of a delivery row: the match query and this write
* then re-pick independently and can land on different rows, so the enrichment
* reports success against a row nobody reads.
*/
export type LineItemsSource = "intake" | "pantry";
@@ -55,10 +61,35 @@ export type ReplaceResult = {
line_items: LineItemRecord[];
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* The undo baseline, recorded once per batch.
*
* Returning the row that was just read makes a RETRY destroy the thing undo
* exists to restore: pantry re-calls this on every confirm and after a timeout,
* and the second call would hand back its own write as the "previous" state.
* The first snapshot a batch ever takes is the only one worth keeping, so it is
* written under COALESCE and read back rather than trusted.
*/
async function undoBaseline(batchId: string | null | undefined, snapshot: unknown[]): Promise<unknown[]> {
// Pantry passes back whatever batch id it was given; only an intake batch has
// a row to record against, and a non-uuid would fail the cast.
if (!batchId || !UUID.test(batchId)) return snapshot;
const row = await queryRow<{ previous_line_items: unknown }>(
`UPDATE intake_batches
SET previous_line_items = COALESCE(previous_line_items, $2::jsonb)
WHERE id = $1::uuid
RETURNING previous_line_items`,
[batchId, JSON.stringify(snapshot)]
);
return Array.isArray(row?.previous_line_items) ? row.previous_line_items : snapshot;
}
export async function replaceLineItems(
transactionId: number,
lineItems: LineItemRecord[],
opts: { source: LineItemsSource; platform?: string | null; batchId?: string | null }
opts: { source: LineItemsSource; platform?: string | null; batchId?: string | null; metadataId?: number | null }
): Promise<ReplaceResult> {
const txn = await queryRow<{
id: number;
@@ -73,25 +104,42 @@ export async function replaceLineItems(
);
if (!txn) throw new TransactionNotFoundError(`transaction ${transactionId} does not exist`);
// Prefer the row that IS this transaction over one that merely points at it —
// a statement line can be the `matched_transaction_id` of a receipt row and
// carry its own row too, and the items belong on the receipt.
const existing = await queryRow<{ id: number; line_items: unknown }>(
`SELECT id, line_items FROM expense_metadata
WHERE transaction_id = $1 OR matched_transaction_id = $1
ORDER BY (transaction_id = $1) DESC, id
LIMIT 1`,
[transactionId]
);
// The caller's row wins where it still exists; otherwise prefer the row that
// IS this transaction over one that merely points at it — a statement line
// can be the `matched_transaction_id` of a receipt row and carry its own row
// too, and the items belong on the receipt.
const pinned = opts.metadataId
? await queryRow<{ id: number; line_items: unknown }>(
`SELECT id, line_items FROM expense_metadata
WHERE id = $1 AND (transaction_id = $2 OR matched_transaction_id = $2)`,
[opts.metadataId, transactionId]
)
: null;
const existing =
pinned ??
(await queryRow<{ id: number; line_items: unknown }>(
`SELECT id, line_items FROM expense_metadata
WHERE transaction_id = $1 OR matched_transaction_id = $1
-- NULLS LAST is load-bearing, and its absence inverted this clause.
-- Postgres orders DESC as NULLS FIRST, and the row that merely POINTS
-- at the transaction has a NULL transaction_id — so the comparison
-- meant to prefer the owned row preferred the other one, silently and
-- in exactly the case it was written for. matchTransaction spells it
-- out; this did not, and the two therefore disagreed.
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
LIMIT 1`,
[transactionId]
));
const json = JSON.stringify(lineItems);
if (existing) {
const previous = await undoBaseline(opts.batchId, Array.isArray(existing.line_items) ? existing.line_items : []);
await queryRaw(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE id = $1`, [existing.id, json]);
return {
transaction_id: transactionId,
metadata_id: existing.id,
created_metadata: false,
previous_line_items: Array.isArray(existing.line_items) ? existing.line_items : [],
previous_line_items: previous,
line_items: lineItems,
};
}
@@ -118,7 +166,7 @@ export async function replaceLineItems(
transaction_id: transactionId,
metadata_id: created!.id,
created_metadata: true,
previous_line_items: [],
previous_line_items: await undoBaseline(opts.batchId, []),
line_items: lineItems,
};
}