Files
finance-app/src/lib/receipt-ingestion.ts
T
siddharthd a465b147a3
ci / lint-test (push) Successful in 48s
Read receipt lines under the keys the receipt panel renders
The order-details panel reads qty/description/amount from
expense_metadata.line_items. A grocery shop is a receipt like any other, so it
stores the same keys rather than name/quantity/line_total — otherwise the rows
arrive complete and display blank, which is exactly the failure ING-8 names.
unit and category are the two fields a grocery line has and a delivery line does
not; nothing renders them yet and the category composition will.
2026-07-30 13:51:33 +10:00

222 lines
11 KiB
TypeScript

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<ReceiptIngestResult> {
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`
);
}