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
+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;
}
}