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.
77 lines
3.1 KiB
TypeScript
77 lines
3.1 KiB
TypeScript
import type { StoredFile } from "@/lib/intake-files";
|
|
|
|
/**
|
|
* finance → pantry hand-off for a grocery upload (plan §11 C3).
|
|
*
|
|
* The ORIGINAL files go across, not the extracted items: pantry owns grocery
|
|
* extraction (canonical names, categories, staples) and its capture board is
|
|
* the review surface. What finance adds is the routing decision — which
|
|
* transaction the confirm should enrich, or that it should create one — carried
|
|
* in the `intake` field so pantry's confirm seam (C4) can suppress the ING-10
|
|
* create that would otherwise double-book an order the email already booked.
|
|
*/
|
|
|
|
export type PantryForwardArgs = {
|
|
files: StoredFile[];
|
|
batchId: string;
|
|
platform: string | null;
|
|
sharedBy: string | null;
|
|
financeTargetTransactionId: number | null;
|
|
financeAction: "enrich" | "create";
|
|
};
|
|
|
|
export type PantryForwardResult = { captureId: number; itemCount: number | null };
|
|
|
|
export class PantryDuplicateError extends Error {
|
|
constructor(
|
|
public readonly captureId: number | null,
|
|
public readonly reason: string
|
|
) {
|
|
super(reason);
|
|
}
|
|
}
|
|
|
|
export class PantryUnavailableError extends Error {}
|
|
|
|
export type PantryForward = (args: PantryForwardArgs) => Promise<PantryForwardResult>;
|
|
|
|
export function pantryForwarder(opts: { baseUrl?: string; token?: string; fetchImpl?: typeof fetch } = {}): PantryForward {
|
|
return async (args) => {
|
|
const baseUrl = (opts.baseUrl ?? process.env.PANTRY_BASE_URL ?? "").replace(/\/$/, "");
|
|
const token = opts.token ?? process.env.RECEIPT_INGEST_TOKEN;
|
|
if (!baseUrl || !token) throw new PantryUnavailableError("PANTRY_BASE_URL / RECEIPT_INGEST_TOKEN are not configured");
|
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
|
|
const form = new FormData();
|
|
for (const f of args.files) {
|
|
form.append("image", new Blob([new Uint8Array(f.bytes)], { type: f.type }), f.name);
|
|
}
|
|
form.append("source", "receipt");
|
|
form.append(
|
|
"intake",
|
|
JSON.stringify({
|
|
batch_id: args.batchId,
|
|
platform: args.platform,
|
|
origin: "intake",
|
|
shared_by: args.sharedBy,
|
|
finance_target_transaction_id: args.financeTargetTransactionId,
|
|
finance_action: args.financeAction,
|
|
})
|
|
);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await doFetch(`${baseUrl}/api/capture`, { method: "POST", headers: { "x-ingest-token": token }, body: form });
|
|
} catch (e) {
|
|
throw new PantryUnavailableError(`pantry unreachable: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
const body = (await response.json().catch(() => ({}))) as { captureId?: number; error?: string; items?: unknown[]; reason?: string };
|
|
if (response.status === 409) {
|
|
throw new PantryDuplicateError(typeof body.captureId === "number" ? body.captureId : null, body.reason ?? body.error ?? "already captured");
|
|
}
|
|
if (!response.ok) throw new PantryUnavailableError(`pantry returned ${response.status}: ${body.error ?? ""}`.trim());
|
|
if (typeof body.captureId !== "number") throw new PantryUnavailableError("pantry returned no captureId");
|
|
return { captureId: body.captureId, itemCount: Array.isArray(body.items) ? body.items.length : null };
|
|
};
|
|
}
|