export type ReceiptParseResult = { amount: number | null; date: Date | null; subtotal: number | null; tax: number | null; vendor: string | null; items: ReceiptLineItem[]; rawLines: string[]; }; export type ReceiptLineItem = { id: string; name: string; amount: number; rawLine: string; }; const AMOUNT_PATTERNS = [ /(?:total|amount due|balance due|grand total)[:\s]*\$?\s*([\d,]+\.\d{2})/i, /\$\s*([\d,]+\.\d{2})\s*(?:total|due)?/i, /(?:USD|CAD|EUR)\s*([\d,]+\.\d{2})/i, ]; const DATE_PATTERNS = [ /(\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4})/, /(\d{4}[/.-]\d{1,2}[/.-]\d{1,2})/, ]; const SUBTOTAL_PATTERNS = [ /(?:sub\s?total|subtotal)[:\s]*\$?\s*([\d,]+\.\d{2})/i, ]; const TAX_PATTERNS = [ /(?:tax|sales tax|hst|gst|pst|vat)[:\s]*\$?\s*([\d,]+\.\d{2})/i, ]; const NON_ITEM_LINE = /(?:total|subtotal|sub total|tax|tip|gratuity|change|cash|visa|mastercard|amex|discover|card|credit|debit|balance|amount due|auth|approval|terminal|merchant|receipt|order|invoice|thank|powered by)/i; function parseAmount(text: string): number | null { for (const pattern of AMOUNT_PATTERNS) { const match = text.match(pattern); if (!match?.[1]) continue; const value = Number(match[1].replace(/,/g, "")); if (Number.isFinite(value) && value > 0) return value; } const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)] .map((m) => Number(m[1]!.replace(/,/g, ""))) .filter((n) => Number.isFinite(n) && n > 0); return amounts.length > 0 ? Math.max(...amounts) : null; } function parseFirstMatchingAmount( text: string, patterns: RegExp[], ): number | null { for (const pattern of patterns) { const match = text.match(pattern); if (!match?.[1]) continue; const value = Number(match[1].replace(/,/g, "")); if (Number.isFinite(value) && value >= 0) return value; } return null; } function parseDate(text: string): Date | null { for (const pattern of DATE_PATTERNS) { const match = text.match(pattern); if (!match?.[1]) continue; const parsed = new Date(match[1]); if (!Number.isNaN(parsed.getTime())) return parsed; } return null; } function parseVendor(lines: string[]): string | null { const candidate = lines.find((line) => line.trim().length >= 3); return candidate?.trim().slice(0, 120) ?? null; } function parseLineItems(lines: string[]): ReceiptLineItem[] { const items: ReceiptLineItem[] = []; for (const [index, rawLine] of lines.entries()) { const line = rawLine.replace(/\s+/g, " ").trim(); if (line.length < 5 || NON_ITEM_LINE.test(line)) continue; const match = line.match(/^(.{2,}?)\s+\$?(-?[\d,]+\.\d{2})$/); if (!match?.[1] || !match[2]) continue; const amount = Number(match[2].replace(/,/g, "")); const name = match[1] .replace(/^\d+\s*[xX]\s+/, "") .replace(/\s+\d+\s*[xX]\s*$/, "") .trim(); if (!Number.isFinite(amount) || amount <= 0 || name.length < 2) continue; items.push({ id: `${index}-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${amount.toFixed(2)}`, name: name.slice(0, 80), amount, rawLine, }); } return items.slice(0, 30); } export function parseReceiptText(text: string): ReceiptParseResult { const normalized = text.replace(/\r/g, "\n").trim(); const rawLines = normalized .split("\n") .map((line) => line.trim()) .filter(Boolean); return { amount: parseAmount(normalized), date: parseDate(normalized), subtotal: parseFirstMatchingAmount(normalized, SUBTOTAL_PATTERNS), tax: parseFirstMatchingAmount(normalized, TAX_PATTERNS), vendor: parseVendor(rawLines), items: parseLineItems(rawLines), rawLines, }; }