Files
TREK/shared/src/budget/receipt-scan.schema.ts
T
Maurice 237f42a493 fix(llm-parse): harden photo reading and finish the receipt scan
A photo is now decoded only when its header says it is within 40 megapixels,
and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A
scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image
above the same pixel cap instead of decoding it.

The receipt reading is held to bounds in the shared contract, keeps the minus
sign of a discount and leaves such lines out so the Ticket split can save, and
its prompt states the JSON shape, so a server without json_schema still answers
something the parser reads. The model lookup sends the configured key, the
import error names the photo formats, the scanned photo is attached only for
someone who may upload files, and the phone settlement reloads after a receipt
is saved from the trip sheets. Test ids that clashed after the rebase are
renumbered, and the wiki describes the image setting, photo import and receipt
scan.
2026-09-26 15:59:05 +02:00

67 lines
2.6 KiB
TypeScript

import { z } from 'zod';
/**
* The bounds a model's reading of a receipt is held to. The answer comes from a
* photo anyone on the trip can take, of paper anyone can print, so it is never
* trusted to be short or sane: a name longer than a till prints, more lines
* than one expense is split into, or an amount no receipt carries is cut or
* dropped before it reaches the expense editor.
*/
export const RECEIPT_TEXT_MAX = 200;
export const RECEIPT_LINES_MAX = 100;
/**
* Above any receipt in any currency, the weakest included, and far enough
* under 2^53 for the integer cent maths of a split.
*/
export const RECEIPT_AMOUNT_MAX = 10_000_000_000;
/**
* A photographed receipt, as the AI model read it for the Costs tab.
*
* It only pre-fills the expense editor: nothing is saved until the person saves
* the expense there, so every field may be missing and each is checked by eye.
* There is no category: which kind of spending it was is the person's call.
*/
export const receiptLineSchema = z.object({
name: z.string().min(1).max(RECEIPT_TEXT_MAX),
/**
* What the line cost in total, quantity included, in the receipt's currency.
* Positive: a discount or a free line would leave the Ticket split unable to
* save, which takes only priced lines, so it is not a line here. The total
* already counts it.
*/
price: z.number().positive().max(RECEIPT_AMOUNT_MAX),
});
export type ReceiptLine = z.infer<typeof receiptLineSchema>;
export const receiptReadSchema = z.object({
/** The business, which becomes the expense name. */
merchant: z.string().min(1).max(RECEIPT_TEXT_MAX).nullable(),
/** YYYY-MM-DD. */
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable(),
/** The grand total paid. */
total: z.number().positive().max(RECEIPT_AMOUNT_MAX).nullable(),
/** ISO 4217. */
currency: z
.string()
.regex(/^[A-Z]{3}$/)
.nullable(),
/** The itemized lines, which seed the Ticket split. */
items: z.array(receiptLineSchema).max(RECEIPT_LINES_MAX),
});
export type ReceiptRead = z.infer<typeof receiptReadSchema>;
/** What a finished receipt scan job answers: the read, or null with the reason in `warnings`. */
export const receiptScanResultSchema = z.object({
receipt: receiptReadSchema.nullable(),
warnings: z.array(z.string()),
});
export type ReceiptScanResult = z.infer<typeof receiptScanResultSchema>;
/** POST /api/trips/:tripId/budget/receipt-scan: the background job reading the photo. */
export const receiptScanStartResponseSchema = z.object({ jobId: z.string().min(1) });
export type ReceiptScanStartResponse = z.infer<typeof receiptScanStartResponseSchema>;