ci / lint-test (push) Successful in 48s
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.
222 lines
11 KiB
TypeScript
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`
|
|
);
|
|
}
|