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:
@@ -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 };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user