import { queryRow } from "@/lib/db"; /** * Replace the itemised receipt behind a transaction (plan §11 C2). * * An idempotent UPDATE, never an INSERT of a second row: `expense_metadata. * transaction_id` is UNIQUE and the order the email pipeline booked already has * its row — with `line_items = []`, because the items sat behind an auth-gated * link. Idempotence is also what lets pantry re-run it after every confirm, * writing the board's current state (plan §5, Codex #3), and what lets undo * restore `previous_line_items` through the same call. * * The row is found on `transaction_id = :id OR matched_transaction_id = :id` — * a card-settled order has only the latter (CLAUDE.md trap). When the * 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"; export class TransactionNotFoundError extends Error {} export class LineItemsValidationError extends Error {} export type LineItemRecord = Record & { description: string }; /** * Permissive on purpose: the order lane writes `{qty, description, amount, * options}`, the receipt lane `{description, qty, amount, unit, category}`, and * the detail panel reads the overlap. What every producer must supply is a * description — an item with no name renders as nothing. */ export function validateLineItems(input: unknown): LineItemRecord[] { if (!Array.isArray(input)) throw new LineItemsValidationError("line_items must be an array"); return input.map((raw, i) => { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { throw new LineItemsValidationError(`line_items[${i}] must be an object`); } const item = { ...(raw as Record) }; if (typeof item.description !== "string" || !item.description.trim()) { throw new LineItemsValidationError(`line_items[${i}].description is required`); } item.description = item.description.trim(); // `quantity` is the extractor's word; `qty` is what the renderer reads. if (item.qty === undefined && typeof item.quantity === "number") item.qty = item.quantity; return item as LineItemRecord; }); } export type ReplaceResult = { transaction_id: number; metadata_id: number; created_metadata: boolean; previous_line_items: unknown[]; 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 { // 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; metadataId?: number | null; currency?: string | null } ): Promise { const txn = await queryRow<{ id: number; merchant_normalized: string | null; merchant_name: string | null; amount: string; transaction_date: string; }>( `SELECT id, merchant_normalized, merchant_name, amount::text, transaction_date::text FROM transactions WHERE id = $1`, [transactionId] ); if (!txn) throw new TransactionNotFoundError(`transaction ${transactionId} does not exist`); // 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 json = JSON.stringify(lineItems); /** * Snapshot the baseline, then write. In that order on BOTH paths: the create * path used to snapshot after its INSERT, so a crash in between left a retry * recording the intake's own write as "previous" — undo restoring the thing * it was meant to reverse, which is the failure `previous_line_items` exists * to prevent. * * Returns null when the row is gone between the read and the write. That * UPDATE reported success and the batch was marked enriched against a row * nothing had written to. */ const write = async (row: { id: number; line_items: unknown }): Promise => { const previous = await undoBaseline(opts.batchId, Array.isArray(row.line_items) ? row.line_items : []); const updated = await queryRow<{ id: number }>( `UPDATE expense_metadata SET line_items = $2::jsonb WHERE id = $1 RETURNING id`, [row.id, json] ); if (!updated) return null; return { transaction_id: transactionId, metadata_id: row.id, created_metadata: false, previous_line_items: previous, line_items: lineItems }; }; if (pinned) { const done = await write(pinned); if (done) return done; } 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 -- 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] ); if (existing) { const done = await write(existing); if (done) return done; } // Nothing to update. Snapshot the empty baseline BEFORE creating the row. const previous = await undoBaseline(opts.batchId, []); const reference = opts.batchId ? `intake:${opts.batchId}` : `intake:txn:${transactionId}`; // ON CONFLICT because two calls for one transaction can both find no row and // both INSERT; expense_metadata.transaction_id is UNIQUE, so one of them lost // with a constraint error that reached the user as an error card. `xmax = 0` // is how a plain insert is told from a conflict-update, so the reported // `created_metadata` stays true. const created = await queryRow<{ id: number; inserted: boolean }>( `INSERT INTO expense_metadata ( transaction_id, source, platform, order_reference, line_items, merchant_normalized, amount, transaction_date, currency, flags, extraction_model ) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::numeric, $8::date, $9, '[]'::jsonb, 'cloud-budget') ON CONFLICT (transaction_id) DO UPDATE SET line_items = EXCLUDED.line_items RETURNING id, (xmax = 0) AS inserted`, [ transactionId, opts.source, opts.platform ?? null, reference, json, txn.merchant_normalized ?? txn.merchant_name ?? null, txn.amount, txn.transaction_date, // Not hard-coded 'AUD': the transaction may carry a foreign figure, and // OrderDetails prefers this column, so a USD order printed "$". opts.currency || "AUD", ] ); return { transaction_id: transactionId, metadata_id: created!.id, created_metadata: created!.inserted, previous_line_items: previous, line_items: lineItems, }; }