intake: POST /api/intake router + PUT /api/transactions/[id]/line-items (plan §11 C1, C2)
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.
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { queryRow, queryRaw } 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.
|
||||
*/
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
export async function replaceLineItems(
|
||||
transactionId: number,
|
||||
lineItems: LineItemRecord[],
|
||||
opts: { source: LineItemsSource; platform?: string | null; batchId?: 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`);
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
const json = JSON.stringify(lineItems);
|
||||
if (existing) {
|
||||
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 : [],
|
||||
line_items: lineItems,
|
||||
};
|
||||
}
|
||||
|
||||
const reference = opts.batchId ? `intake:${opts.batchId}` : `intake:txn:${transactionId}`;
|
||||
const created = await queryRow<{ id: number }>(
|
||||
`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, 'AUD', '[]'::jsonb, 'cloud-budget')
|
||||
RETURNING id`,
|
||||
[
|
||||
transactionId,
|
||||
opts.source,
|
||||
opts.platform ?? null,
|
||||
reference,
|
||||
json,
|
||||
txn.merchant_normalized ?? txn.merchant_name ?? null,
|
||||
txn.amount,
|
||||
txn.transaction_date,
|
||||
]
|
||||
);
|
||||
return {
|
||||
transaction_id: transactionId,
|
||||
metadata_id: created!.id,
|
||||
created_metadata: true,
|
||||
previous_line_items: [],
|
||||
line_items: lineItems,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user