94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
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 = [
|
|
/^\s*(?:grand total|total|amount due|balance due)[:\s]*\$?\s*([\d,]+\.\d{2})/im,
|
|
/\$\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 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 parseAmount(text: string): number | null {
|
|
const labeledAmount = parseFirstMatchingAmount(text, AMOUNT_PATTERNS);
|
|
if (labeledAmount != null && labeledAmount > 0) return labeledAmount;
|
|
const amounts = [...text.matchAll(/\$\s*([\d,]+\.\d{2})/g)]
|
|
.map((match) => Number(match[1]!.replace(/,/g, "")))
|
|
.filter((amount) => Number.isFinite(amount) && amount > 0);
|
|
return amounts.length > 0 ? Math.max(...amounts) : 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 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: rawLines.find((line) => line.length >= 3)?.slice(0, 120) ?? null,
|
|
items: parseLineItems(rawLines),
|
|
rawLines,
|
|
};
|
|
}
|