import { queryRaw, queryRow } from "@/lib/db"; import { DEFAULT_OWNER_ID } from "@/lib/order-ingestion"; /** * Grocery receipts scanned in pantry-app, arriving as candidate spend. * * The shape deliberately mirrors the order lane rather than inventing a second mechanism, * but it makes one decision differently and the difference is the point: **nothing is * parked.** An order can wait for its statement because it is already visible as an email; * a gift-card grocery shop is visible nowhere at all, so a scan that does not produce a * transaction produces nothing a person can see. Every leg becomes a manual transaction * (`statement_id IS NULL`) immediately, and finance's existing pending-reconciliation queue * resolves the ones that have a card leg coming. * * Why one transaction per tender leg rather than one per receipt: a shop settled $40.75 on * a gift card and $73.82 on a Mastercard has a statement line for $73.82 and nothing for * the rest. A single $114.57 row marked `credits` is excluded from the queue and lets the * statement line double-count; marked `card` it is searched for at ±1% of $114.57 and never * matches, so it parks forever while the statement line still counts. Either way the shop * books $188.39. Per-leg rows make each amount exactly the settled amount, so the existing * matcher works untouched. */ export type TenderLeg = { leg_index: number; amount: number; card_last4?: string | null; card_product?: string | null; /** pantry's vocabulary; mapped to finance's payment_method below. */ class?: "card" | "gift_card" | "cash" | null; }; /** * Same keys the order lane uses, because the panel that renders an itemised receipt reads * `qty`/`description`/`amount` and a grocery shop is a receipt like any other. `unit` and * `category` are the two things a grocery line has and a delivery line does not; nothing * renders them yet, and the intra-transaction category composition will. */ export type ReceiptLineItem = { description: string; qty?: number | null; amount?: number | null; unit?: string | null; category?: string | null; }; export type ParsedReceipt = { receipt_uid?: string | null; capture_event_id: number; image_sha256?: string | null; merchant_name: string; store_detail?: string | null; transaction_date: string; total: number; tax_amount?: number | null; tender_raw?: string | null; loyalty_card_number?: string | null; tender_legs: TenderLeg[]; line_items: ReceiptLineItem[]; }; export type ReceiptIngestResult = { group: string; legs: { leg_index: number; transactionId: number; metadataId: number; paymentMethod: string | null; skipped?: string }[]; flags: string[]; }; export class ReceiptValidationError extends Error {} /** * `credits` rather than a new `gift_card`: prepaid value with no card leg is a concept * finance already has, and reusing it inherits both the `needsCardMatch()` exclusion that * keeps such rows out of the reconciliation queue and the "Gift Card" label `bankLabel()` * renders for them. A second word for one idea would have needed both taught again. * * An unknown class maps to NULL, which `needsCardMatch()` treats as reconcilable — so a leg * nobody could classify lands in the queue visibly unresolved instead of being silently * decided either way. */ function paymentMethodFor(legClass: TenderLeg["class"]): string | null { if (legClass === "gift_card") return "credits"; if (legClass === "cash") return "cash"; if (legClass === "card") return "card"; return null; } const round2 = (value: number) => Number(value.toFixed(2)); /** * The whole shop is `groceries`. What it was actually made of — food versus household — * stays derived from `line_items` at display time rather than stored, because a mixed shop * is one payment and splitting the transaction to describe it would make the amount that * reconciles against the statement line stop matching it. */ const RECEIPT_CATEGORY = "groceries"; export function validateReceipt(receipt: ParsedReceipt): string[] { const flags: string[] = []; if (!receipt.tender_legs?.length) throw new ReceiptValidationError("at least one tender leg is required"); if (!Number.isFinite(receipt.total) || receipt.total <= 0) throw new ReceiptValidationError("total must be a positive number"); if (!/^\d{4}-\d{2}-\d{2}$/.test(receipt.transaction_date)) throw new ReceiptValidationError("transaction_date must be YYYY-MM-DD"); // The check that detects a split at all, and the one that proves the payment side was // read whole. A leg missed here becomes spend that never appears. const legSum = round2(receipt.tender_legs.reduce((total, leg) => total + Number(leg.amount || 0), 0)); if (Math.abs(legSum - round2(receipt.total)) > 0.02) { throw new ReceiptValidationError(`tender legs sum to ${legSum.toFixed(2)} but the receipt total is ${receipt.total.toFixed(2)}`); } // Lines are allowed to disagree: promotional rows are deliberately skipped during // extraction, so this flags rather than rejects. The money is the tender, not the lines. const lineSum = round2((receipt.line_items ?? []).reduce((total, line) => total + Number(line.amount || 0), 0)); if (receipt.line_items?.length && Math.abs(lineSum - round2(receipt.total)) > 0.02) flags.push(`line_items_sum_${lineSum.toFixed(2)}`); if (receipt.tender_legs.length > 1) flags.push("split_tender"); if (receipt.tender_legs.some((leg) => !leg.class)) flags.push("unclassified_tender"); return flags; } /** * The receipt's own identity, not the capture's. A photo and the store's e-receipt PDF of * one purchase are different files with different hashes, so keying on the capture would * let the same shop arrive twice as two unrelated sets of transactions. Falls back to the * capture id when the receipt did not print enough to identify itself — that risks a * duplicate, which is visible and removable, rather than a merge, which silently hides a * real shop. */ export function receiptGroup(receipt: ParsedReceipt): string { return `pantry:${receipt.receipt_uid || `capture:${receipt.capture_event_id}`}`; } export async function processReceiptIngestion(receipt: ParsedReceipt): Promise { const flags = validateReceipt(receipt); const group = receiptGroup(receipt); // Line items describe the whole shop but can only attach to one row — // expense_metadata.transaction_id is UNIQUE, and duplicating them would double any // composition derived from them. They go on the card leg because that is the row which // reconciles onto the statement line, which is where an unreadable `COLES 0556` descriptor // actually gets its contents. With no card leg, the largest leg carries them. const cardLeg = receipt.tender_legs.find((leg) => leg.class === "card" || !leg.class) ?? [...receipt.tender_legs].sort((a, b) => Number(b.amount) - Number(a.amount))[0]; const legs: ReceiptIngestResult["legs"] = []; for (const leg of receipt.tender_legs) { const reference = `${group}#${leg.leg_index}`; const existing = await queryRow<{ id: number; transaction_id: number | null }>( `SELECT id, transaction_id FROM expense_metadata WHERE source = 'pantry' AND order_reference = $1`, [reference] ); if (existing) { legs.push({ leg_index: leg.leg_index, transactionId: existing.transaction_id ?? 0, metadataId: existing.id, paymentMethod: null, skipped: "already_ingested" }); continue; } const paymentMethod = paymentMethodFor(leg.class); const amount = round2(Number(leg.amount)); const description = receipt.store_detail ? `${receipt.merchant_name} ${receipt.store_detail}` : receipt.merchant_name; const txn = await queryRow<{ id: number }>( `INSERT INTO transactions ( transaction_date, description, amount, amount_aud, category, payment_method, merchant_name, merchant_normalized, transaction_type, owner_id ) VALUES ($1,$2,$3,$3,$4,$5,$6,$6,'debit',$7) RETURNING id`, [receipt.transaction_date, description, amount, RECEIPT_CATEGORY, paymentMethod, receipt.merchant_name, DEFAULT_OWNER_ID] ); const carriesLines = leg.leg_index === cardLeg?.leg_index; const meta = await queryRow<{ id: number }>( `INSERT INTO expense_metadata ( transaction_id, source, order_reference, line_items, subtotal, amount, merchant_normalized, transaction_date, card_last4, currency, flags, reconciled_at, payment_method, payment_method_detail, tender_raw, receipt_sha256, receipt_group, extraction_model ) VALUES ($1,'pantry',$2,$3::jsonb,$4,$5,$6,$7,$8,'AUD',$9::jsonb,NULL,$10,$11,$12,$13,$14,'cloud-budget') RETURNING id`, [ txn!.id, reference, JSON.stringify(carriesLines ? receipt.line_items ?? [] : []), receipt.tax_amount ?? null, amount, receipt.merchant_name, receipt.transaction_date, leg.card_last4 ?? null, JSON.stringify(carriesLines ? flags : [...flags, "line_items_on_card_leg"]), paymentMethod, leg.card_product ?? null, receipt.tender_raw ?? null, receipt.image_sha256 ?? null, group, ] ); legs.push({ leg_index: leg.leg_index, transactionId: txn!.id, metadataId: meta!.id, paymentMethod }); } return { group, legs, flags }; } /** * Statement lines that look like a pantry row already marked as needing no card leg. * * Such rows never enter the reconciliation queue — that is the whole point of classifying * them — so a mis-learned card would otherwise double-count in silence. This only flags: the * same uncertainty that makes the classification fallible makes the match a suggestion, and * auto-reconciling on it would trade a visible error for an invisible one. A confirmed * conflict is a reason to correct the card's stored class, which fixes every later receipt * from it at once. */ export async function pantryTenderConflicts(): Promise<{ transactionId: number; statementTransactionId: number; amount: string; merchant: string | null; date: string }[]> { return queryRaw( `SELECT p.id AS "transactionId", s.id AS "statementTransactionId", p.amount::text AS amount, p.merchant_normalized AS merchant, p.transaction_date::text AS date FROM transactions p JOIN expense_metadata em ON em.transaction_id = p.id AND em.source = 'pantry' JOIN transactions s ON s.statement_id IS NOT NULL AND s.transaction_date BETWEEN p.transaction_date - 3 AND p.transaction_date + 3 AND s.amount BETWEEN p.amount * 0.99 AND p.amount * 1.01 AND upper(coalesce(s.description, '')) LIKE '%' || upper(p.merchant_name) || '%' WHERE p.statement_id IS NULL AND p.reconciled_with_id IS NULL AND p.payment_method IN ('cash', 'credits') ORDER BY p.transaction_date DESC` ); }