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 "$".
201 lines
8.8 KiB
TypeScript
201 lines
8.8 KiB
TypeScript
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<string, unknown> & { 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<string, unknown>) };
|
|
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<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; metadataId?: number | null; currency?: string | null }
|
|
): Promise<ReplaceResult> {
|
|
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<ReplaceResult | null> => {
|
|
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,
|
|
};
|
|
}
|