Add mobile expenses and receipt OCR

This commit is contained in:
2026-06-29 01:35:00 -04:00
parent 8a3f498874
commit 8ad4210908
18 changed files with 2460 additions and 4 deletions
+11
View File
@@ -0,0 +1,11 @@
export const EXPENSE_CATEGORIES = [
"Travel",
"Meals & Entertainment",
"Software & Subscriptions",
"Hardware & Equipment",
"Office Supplies",
"Marketing",
"Professional Services",
"Utilities",
"Other",
] as const;
+50
View File
@@ -0,0 +1,50 @@
import { isSupported, recognizeText } from "expo-mlkit-ocr";
import { parseReceiptText, type ReceiptParseResult } from "@/lib/receipt-parse";
export type ReceiptOcrResult = ReceiptParseResult & {
rawText: string;
};
export function mlKitOcrAvailable(): boolean {
try {
return isSupported();
} catch {
return false;
}
}
function normalizeImageUri(uri: string): string {
if (uri.startsWith("file://") || uri.startsWith("content://")) {
return uri;
}
return `file://${uri}`;
}
/** Run on-device ML Kit OCR and parse receipt fields from recognized text. */
export async function recognizeReceiptFromImage(uri: string): Promise<ReceiptOcrResult> {
if (!mlKitOcrAvailable()) {
throw new Error("On-device OCR is not supported on this device.");
}
const result = await recognizeText(normalizeImageUri(uri));
const rawText = result.text?.trim() ?? "";
const parsed = parseReceiptText(rawText);
return {
...parsed,
rawText,
};
}
export function applyReceiptOcrToForm(
ocr: ReceiptParseResult,
current: { description: string; amountText: string; date: Date },
): { description: string; amountText: string; date: Date; ocrText: string } {
return {
description: ocr.vendor?.trim() || current.description,
amountText: ocr.amount != null ? String(ocr.amount) : current.amountText,
date: ocr.date ?? current.date,
ocrText: ocr.rawLines.join("\n"),
};
}
+128
View File
@@ -0,0 +1,128 @@
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,
};
}
+135
View File
@@ -0,0 +1,135 @@
import * as ImagePicker from "expo-image-picker";
import { Alert } from "react-native";
import {
applyReceiptOcrToForm,
recognizeReceiptFromImage,
} from "@/lib/receipt-ocr";
import type { ReceiptLineItem } from "@/lib/receipt-parse";
export type ReceiptSuggestFn = (input: { text: string }) => Promise<{
amount: number | null;
date: Date | null;
description: string | null;
}>;
export type PickedReceiptImage = {
uri: string;
base64: string;
filename: string;
mimeType: string;
};
export type ReceiptScanResult = {
image: PickedReceiptImage;
description: string;
amountText: string;
date: Date;
ocrText: string;
items: ReceiptLineItem[];
subtotal: number | null;
tax: number | null;
total: number | null;
};
async function requestPermissions(fromCamera: boolean): Promise<boolean> {
const permission = fromCamera
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.granted) return true;
Alert.alert(
fromCamera ? "Camera access needed" : "Photos access needed",
fromCamera
? "Allow camera access to scan receipts."
: "Allow photo library access to import receipt images.",
);
return false;
}
export async function pickReceiptImage(
fromCamera: boolean,
): Promise<PickedReceiptImage | null> {
if (!(await requestPermissions(fromCamera))) return null;
const result = fromCamera
? await ImagePicker.launchCameraAsync({
mediaTypes: ["images"],
quality: 0.9,
base64: true,
})
: await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
quality: 0.9,
base64: true,
});
if (result.canceled || !result.assets[0]) return null;
const asset = result.assets[0];
if (!asset.uri || !asset.base64) {
Alert.alert(
"Could not read image",
"Try another photo or lower the image size.",
);
return null;
}
return {
uri: asset.uri,
base64: asset.base64,
filename:
asset.fileName ?? (fromCamera ? "receipt.jpg" : "receipt-import.jpg"),
mimeType: asset.mimeType ?? "image/jpeg",
};
}
export async function scanReceiptImage(
fromCamera: boolean,
current: { description: string; amountText: string; date: Date },
suggest?: ReceiptSuggestFn,
): Promise<ReceiptScanResult | null> {
const image = await pickReceiptImage(fromCamera);
if (!image) return null;
try {
const ocr = await recognizeReceiptFromImage(image.uri);
let next = applyReceiptOcrToForm(ocr, current);
next = { ...next, ocrText: ocr.rawText || next.ocrText };
if (ocr.rawText && suggest) {
try {
const suggestion = await suggest({ text: ocr.rawText });
next = {
description: suggestion.description?.trim() || next.description,
amountText:
suggestion.amount != null
? String(suggestion.amount)
: next.amountText,
date: suggestion.date ? new Date(suggestion.date) : next.date,
ocrText: ocr.rawText,
};
} catch {
// Local parse is enough when server suggest fails offline.
}
}
return {
image,
...next,
items: ocr.items,
subtotal: ocr.subtotal,
tax: ocr.tax,
total: ocr.amount,
};
} catch (error) {
Alert.alert(
"OCR failed",
error instanceof Error
? error.message
: "Could not read text from the receipt.",
);
return null;
}
}