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:
2026-08-21 12:10:05 +10:00
parent a538376638
commit db5c9a02a7
12 changed files with 1590 additions and 2 deletions
+76
View File
@@ -0,0 +1,76 @@
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 };
};
}