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
+415
View File
@@ -0,0 +1,415 @@
import { useLocalSearchParams, router } from "expo-router";
import { useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
ExpenseFormFields,
type ExpenseFormState,
} from "@/components/expenses/ExpenseFormFields";
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { scanReceiptImage, type ReceiptScanResult } from "@/lib/receipt-scan";
import { api } from "@/lib/trpc";
type ReceiptSplitDraft = Pick<
ReceiptScanResult,
"items" | "subtotal" | "tax" | "total"
>;
export default function ExpenseDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { colors } = useAppTheme();
const utils = api.useUtils();
const [scanning, setScanning] = useState(false);
const [editing, setEditing] = useState(false);
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
null,
);
const [form, setForm] = useState<ExpenseFormState>({
description: "",
amountText: "",
date: new Date(),
category: "",
businessId: "",
clientId: "",
billable: false,
reimbursable: false,
taxDeductible: false,
notes: "",
});
const expenseQuery = api.expenses.getById.useQuery(
{ id: id ?? "" },
{ enabled: Boolean(id) },
);
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
onSuccess: () => void expenseQuery.refetch(),
});
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
onSuccess: () => void expenseQuery.refetch(),
});
const updateExpense = api.expenses.update.useMutation({
onSuccess: async () => {
await utils.expenses.getAll.invalidate();
await expenseQuery.refetch();
setEditing(false);
},
});
const suggest = api.expenses.suggestFromReceiptText.useMutation();
const expense = expenseQuery.data;
const businesses = businessesQuery.data ?? [];
const clients = clientsQuery.data ?? [];
async function attachAndScan(fromCamera: boolean) {
if (!id || !expense) return;
setScanning(true);
try {
const result = await scanReceiptImage(
fromCamera,
{
description: expense.description,
amountText: String(expense.amount),
date: new Date(expense.date),
},
(input) => suggest.mutateAsync(input),
);
if (!result) return;
await uploadReceipt.mutateAsync({
expenseId: id,
filename: result.image.filename,
mimeType: result.image.mimeType,
data: result.image.base64,
});
setForm(
expenseToForm(expense, {
description: result.description,
amountText: result.amountText,
date: result.date,
notes: result.ocrText,
}),
);
setReceiptSplit(
result.items.length > 0
? {
items: result.items,
subtotal: result.subtotal,
tax: result.tax,
total: result.total,
}
: null,
);
setEditing(true);
Alert.alert(
"Receipt attached",
result.items.length > 0
? "Select the owed items, apply the split amount, then save the expense."
: "OCR filled the fields below. Save to update this expense.",
);
} finally {
setScanning(false);
}
}
function handleSaveEdits() {
if (!id) return;
const amount = Number(form.amountText);
if (!form.description.trim() || !Number.isFinite(amount) || amount <= 0) {
Alert.alert("Invalid fields", "Description and amount are required.");
return;
}
updateExpense.mutate({
id,
description: form.description.trim(),
amount,
date: form.date,
category: form.category || undefined,
businessId: form.businessId || undefined,
clientId: form.clientId || undefined,
billable: form.billable,
reimbursable: form.reimbursable,
taxDeductible: form.taxDeductible,
notes: form.notes.trim() || undefined,
});
}
function startEditing() {
if (!expense) return;
setForm(expenseToForm(expense));
setReceiptSplit(null);
setEditing(true);
}
if (expenseQuery.isLoading) {
return <LoadingScreen message="Loading expense…" />;
}
if (!expense) {
return (
<AppBackground>
<TabPage>
<Text style={{ color: colors.mutedForeground, padding: spacing.lg }}>
Expense not found
</Text>
</TabPage>
</AppBackground>
);
}
return (
<AppBackground>
<TabPage>
<ScrollView contentContainerStyle={styles.body}>
<PageHeader
title={expense.description}
subtitle={formatDate(expense.date)}
/>
{editing ? (
<>
{receiptSplit ? (
<ReceiptItemSelector
items={receiptSplit.items}
subtotal={receiptSplit.subtotal}
tax={receiptSplit.tax}
total={receiptSplit.total}
onApply={(selection) => {
setForm((current) => ({
...current,
amountText: selection.owedTotal.toFixed(2),
notes: mergeNotes(selection.notes, current.notes),
}));
}}
/>
) : null}
<ExpenseFormFields
value={form}
businesses={businesses}
clients={clients}
onChange={setForm}
/>
<Button
title="Save changes"
loading={updateExpense.isPending}
onPress={handleSaveEdits}
/>
<Button
title="Cancel edit"
variant="secondary"
onPress={() => setEditing(false)}
/>
</>
) : (
<>
<Text style={[styles.amount, { color: colors.foreground }]}>
{formatCurrency(expense.amount, expense.currency)}
</Text>
<View style={styles.metaStack}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
{expense.category || "No category"}
{expense.business?.name ? ` · ${expense.business.name}` : ""}
{expense.client?.name ? ` · ${expense.client.name}` : ""}
</Text>
<View style={styles.badges}>
{expense.billable ? (
<Text
style={[
styles.badge,
{ color: colors.primary, borderColor: colors.border },
]}
>
Billable
</Text>
) : null}
{expense.reimbursable ? (
<Text
style={[
styles.badge,
{
color: colors.foreground,
borderColor: colors.border,
},
]}
>
Reimbursable
</Text>
) : null}
{expense.taxDeductible ? (
<Text
style={[
styles.badge,
{ color: colors.success, borderColor: colors.border },
]}
>
Tax deductible
</Text>
) : null}
</View>
</View>
{expense.notes ? (
<Text style={{ color: colors.mutedForeground }}>
{expense.notes}
</Text>
) : null}
<Button
title="Edit expense"
variant="secondary"
onPress={startEditing}
/>
</>
)}
<Text style={[styles.section, { color: colors.foreground }]}>
Receipts ({expense.receipts.length})
</Text>
{expense.receipts.map((receipt) => (
<SwipeableRow
key={receipt.id}
actions={[
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => deleteReceipt.mutate({ id: receipt.id }),
},
]}
>
<Text
style={[styles.receiptRow, { color: colors.mutedForeground }]}
>
{receipt.originalFilename}
</Text>
</SwipeableRow>
))}
<View style={styles.actions}>
<Button
title={scanning ? "Scanning…" : "Scan receipt"}
loading={scanning || uploadReceipt.isPending}
style={styles.actionButton}
onPress={() => void attachAndScan(true)}
/>
<Button
title="Import photo"
variant="secondary"
loading={scanning || uploadReceipt.isPending}
style={styles.actionButton}
onPress={() => void attachAndScan(false)}
/>
</View>
<Button
title="Back"
variant="secondary"
onPress={() => router.back()}
/>
</ScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
},
amount: {
fontSize: 28,
fontWeight: "600",
},
metaStack: {
gap: spacing.sm,
},
meta: {
fontFamily: fonts.body,
fontSize: 14,
},
badges: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.sm,
},
badge: {
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
fontFamily: fonts.bodyMedium,
fontSize: 12,
},
section: {
fontSize: 16,
fontWeight: "600",
marginTop: spacing.md,
},
receiptRow: {
paddingVertical: spacing.sm,
fontSize: 14,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
actionButton: {
flex: 1,
},
});
function expenseToForm(
expense: {
description: string;
amount: number;
date: Date | string;
category: string | null;
businessId: string | null;
clientId: string | null;
billable: boolean;
reimbursable: boolean;
taxDeductible: boolean | null;
notes: string | null;
},
overrides: Partial<ExpenseFormState> = {},
): ExpenseFormState {
return {
description: expense.description,
amountText: String(expense.amount),
date: new Date(expense.date),
category: expense.category ?? "",
businessId: expense.businessId ?? "",
clientId: expense.clientId ?? "",
billable: expense.billable,
reimbursable: expense.reimbursable,
taxDeductible: expense.taxDeductible ?? false,
notes: expense.notes ?? "",
...overrides,
};
}
function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim();
if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) {
const ocrStart = trimmed.indexOf("\n\nOCR text:");
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
}
return `${prefix}\n\nOCR text:\n${trimmed}`;
}
+351
View File
@@ -0,0 +1,351 @@
import { router } from "expo-router";
import { useMemo, useState } from "react";
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { AppBackground } from "@/components/AppBackground";
import { FilterChip } from "@/components/FilterChip";
import { LoadingScreen } from "@/components/LoadingScreen";
import { PageHeader } from "@/components/PageHeader";
import { SwipeableRow } from "@/components/SwipeableRow";
import { TabPage } from "@/components/TabPage";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { formatCurrency, formatDate } from "@/lib/format";
import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
import type { ThemeColors } from "@/lib/theme-palette";
import { useThemedStyles } from "@/lib/use-themed-styles";
import { api } from "@/lib/trpc";
type ExpenseFilter = "all" | "billable" | "receipts";
export default function ExpensesScreen() {
const { colors } = useAppTheme();
const styles = useThemedStyles(createStyles);
const utils = api.useUtils();
const [filter, setFilter] = useState<ExpenseFilter>("all");
const expensesQuery = api.expenses.getAll.useQuery();
const deleteExpense = api.expenses.delete.useMutation({
onSuccess: () => void utils.expenses.getAll.invalidate(),
});
const expenses = expensesQuery.data ?? [];
const filteredExpenses = useMemo(
() =>
expenses.filter((expense) => {
if (filter === "billable") return expense.billable;
if (filter === "receipts") return expense.receiptCount > 0;
return true;
}),
[expenses, filter],
);
const total = filteredExpenses.reduce(
(sum, expense) => sum + expense.amount,
0,
);
const receiptCount = filteredExpenses.reduce(
(sum, expense) => sum + (expense.receiptCount ?? 0),
0,
);
if (expensesQuery.isLoading) {
return <LoadingScreen message="Loading expenses…" />;
}
if (expensesQuery.error) {
return (
<AppBackground>
<TabPage>
<View style={styles.errorBox}>
<Text style={[styles.errorTitle, { color: colors.foreground }]}>
Could not load expenses
</Text>
<Text style={{ color: colors.mutedForeground }}>
{formatTrpcErrorMessage(expensesQuery.error)}
</Text>
</View>
</TabPage>
</AppBackground>
);
}
return (
<AppBackground>
<TabPage>
<TabScrollView
header={
<>
<PageHeader
title="Expenses"
subtitle={`${expenses.length} recorded`}
/>
<Button
title="Add expense"
onPress={() => router.push("/(app)/more/expenses/new" as never)}
/>
</>
}
refreshControl={
<RefreshControl
refreshing={expensesQuery.isRefetching}
onRefresh={() => void expensesQuery.refetch()}
tintColor={colors.primary}
/>
}
>
{expenses.length === 0 ? (
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
No expenses yet. Add one with a receipt photo or manual entry.
</Text>
) : (
<>
<View style={styles.summary}>
<View>
<Text
style={[
styles.summaryLabel,
{ color: colors.mutedForeground },
]}
>
Visible total
</Text>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
>
{formatCurrency(total)}
</Text>
</View>
<View style={styles.summaryRight}>
<Text
style={[
styles.summaryLabel,
{ color: colors.mutedForeground },
]}
>
Receipts
</Text>
<Text
style={[styles.summaryValue, { color: colors.foreground }]}
>
{receiptCount}
</Text>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.filters}
>
<FilterChip
label="All"
active={filter === "all"}
onPress={() => setFilter("all")}
/>
<FilterChip
label="Billable"
active={filter === "billable"}
onPress={() => setFilter("billable")}
/>
<FilterChip
label="With receipts"
active={filter === "receipts"}
onPress={() => setFilter("receipts")}
/>
</ScrollView>
{filteredExpenses.length === 0 ? (
<Text style={[styles.empty, { color: colors.mutedForeground }]}>
No expenses match this filter.
</Text>
) : (
filteredExpenses.map((expense) => (
<SwipeableRow
key={expense.id}
actions={[
{
key: "open",
label: "Open",
icon: "open-outline",
color: "#fff",
backgroundColor: colors.primary,
onPress: () =>
router.push(
`/(app)/more/expenses/${expense.id}` as never,
),
},
{
key: "delete",
label: "Delete",
icon: "trash-outline",
color: "#fff",
backgroundColor: colors.destructive,
onPress: () => deleteExpense.mutate({ id: expense.id }),
},
]}
>
<Pressable
accessibilityRole="button"
onPress={() =>
router.push(
`/(app)/more/expenses/${expense.id}` as never,
)
}
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
]}
>
<View style={styles.meta}>
<View style={styles.titleRow}>
<Text
style={[styles.title, { color: colors.foreground }]}
numberOfLines={1}
>
{expense.description}
</Text>
{expense.receiptCount ? (
<Text
style={[
styles.receiptPill,
{
color: colors.primary,
borderColor: colors.border,
},
]}
>
{expense.receiptCount}
</Text>
) : null}
</View>
<Text
style={[
styles.sub,
{ color: colors.mutedForeground },
]}
numberOfLines={2}
>
{formatDate(expense.date)}
{expense.category ? ` · ${expense.category}` : ""}
{expense.client?.name
? ` · ${expense.client.name}`
: ""}
{expense.billable ? " · Billable" : ""}
</Text>
</View>
<Text
style={[styles.amount, { color: colors.foreground }]}
>
{formatCurrency(expense.amount, expense.currency)}
</Text>
</Pressable>
</SwipeableRow>
))
)}
</>
)}
</TabScrollView>
</TabPage>
</AppBackground>
);
}
const createStyles = (colors: ThemeColors) =>
StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.md,
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
rowPressed: {
opacity: 0.82,
},
meta: {
flex: 1,
gap: 2,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
title: {
flex: 1,
fontFamily: fonts.bodySemiBold,
fontSize: 15,
},
sub: {
fontFamily: fonts.body,
fontSize: 13,
},
amount: {
fontFamily: fonts.bodySemiBold,
fontSize: 15,
fontVariant: ["tabular-nums"],
},
summary: {
flexDirection: "row",
justifyContent: "space-between",
gap: spacing.md,
padding: spacing.md,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.cardGlass,
},
summaryRight: {
alignItems: "flex-end",
},
summaryLabel: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
textTransform: "uppercase",
},
summaryValue: {
marginTop: 2,
fontFamily: fonts.bodySemiBold,
fontSize: 20,
fontVariant: ["tabular-nums"],
},
filters: {
gap: spacing.sm,
paddingRight: spacing.lg,
},
receiptPill: {
minWidth: 26,
textAlign: "center",
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
fontFamily: fonts.bodyMedium,
fontSize: 12,
overflow: "hidden",
},
empty: {
fontFamily: fonts.body,
fontSize: 14,
lineHeight: 20,
},
errorBox: {
padding: spacing.lg,
gap: spacing.sm,
},
errorTitle: {
fontFamily: fonts.bodySemiBold,
fontSize: 18,
},
});
+256
View File
@@ -0,0 +1,256 @@
import { router } from "expo-router";
import { useMemo, useState } from "react";
import { Alert, ScrollView, StyleSheet, Text, View } from "react-native";
import { AppBackground } from "@/components/AppBackground";
import {
defaultExpenseFormState,
ExpenseFormFields,
type ExpenseFormState,
} from "@/components/expenses/ExpenseFormFields";
import { ReceiptItemSelector } from "@/components/expenses/ReceiptItemSelector";
import { PageHeader } from "@/components/PageHeader";
import { TabPage } from "@/components/TabPage";
import { Button } from "@/components/ui/Button";
import { spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { mlKitOcrAvailable } from "@/lib/receipt-ocr";
import {
scanReceiptImage,
type PickedReceiptImage,
type ReceiptScanResult,
} from "@/lib/receipt-scan";
import { api } from "@/lib/trpc";
type ReceiptSplitDraft = Pick<
ReceiptScanResult,
"items" | "subtotal" | "tax" | "total"
>;
export default function NewExpenseScreen() {
const { colors } = useAppTheme();
const utils = api.useUtils();
const businessesQuery = api.businesses.getAll.useQuery();
const clientsQuery = api.clients.getAll.useQuery();
const businesses = businessesQuery.data ?? [];
const clients = clientsQuery.data ?? [];
const defaultBusinessId = useMemo(
() =>
businesses.find((business) => business.isDefault)?.id ??
businesses[0]?.id ??
"",
[businesses],
);
const [form, setForm] = useState<ExpenseFormState>(() =>
defaultExpenseFormState(),
);
const [scanning, setScanning] = useState(false);
const [pendingReceipt, setPendingReceipt] =
useState<PickedReceiptImage | null>(null);
const [receiptSplit, setReceiptSplit] = useState<ReceiptSplitDraft | null>(
null,
);
const createExpense = api.expenses.create.useMutation();
const uploadReceipt = api.expenses.uploadReceipt.useMutation();
const suggest = api.expenses.suggestFromReceiptText.useMutation();
async function runScan(fromCamera: boolean) {
setScanning(true);
try {
const result = await scanReceiptImage(
fromCamera,
{
description: form.description,
amountText: form.amountText,
date: form.date,
},
(input) => suggest.mutateAsync(input),
);
if (!result) return;
setForm((current) => ({
...current,
description: result.description,
amountText: result.amountText,
date: result.date,
notes: result.ocrText,
businessId: current.businessId || defaultBusinessId,
}));
setPendingReceipt(result.image);
setReceiptSplit(
result.items.length > 0
? {
items: result.items,
subtotal: result.subtotal,
tax: result.tax,
total: result.total,
}
: null,
);
Alert.alert(
"Receipt scanned",
result.items.length > 0
? "Select the items this person owes, then apply the split amount."
: result.amountText
? `Filled amount $${result.amountText}${result.description ? ` from ${result.description}` : ""}. Review and save.`
: "Review the fields — OCR could not find a total automatically.",
);
} finally {
setScanning(false);
}
}
async function handleSave() {
const amount = Number(form.amountText);
if (!form.description.trim()) {
Alert.alert("Description required", "Enter what this expense was for.");
return;
}
if (!Number.isFinite(amount) || amount <= 0) {
Alert.alert("Amount required", "Enter a valid amount.");
return;
}
try {
const expense = await createExpense.mutateAsync({
description: form.description.trim(),
amount,
date: form.date,
currency: "USD",
category: form.category || undefined,
clientId: form.clientId || undefined,
businessId: form.businessId || defaultBusinessId || undefined,
billable: form.billable,
reimbursable: form.reimbursable,
taxDeductible: form.taxDeductible,
notes: form.notes.trim() || undefined,
});
if (pendingReceipt) {
await uploadReceipt.mutateAsync({
expenseId: expense.id,
filename: pendingReceipt.filename,
mimeType: pendingReceipt.mimeType,
data: pendingReceipt.base64,
});
}
await utils.expenses.getAll.invalidate();
router.replace(`/(app)/more/expenses/${expense.id}` as never);
} catch (err) {
Alert.alert(
"Could not save expense",
err instanceof Error ? err.message : "Try again.",
);
}
}
return (
<AppBackground>
<TabPage>
<ScrollView
contentContainerStyle={styles.body}
keyboardShouldPersistTaps="handled"
>
<PageHeader
title="New expense"
subtitle={
mlKitOcrAvailable()
? "Scan a receipt with on-device ML Kit OCR"
: "Manual entry (OCR unavailable on this device)"
}
/>
<View style={styles.actions}>
<Button
title={scanning ? "Scanning…" : "Scan receipt"}
variant="secondary"
loading={scanning}
style={styles.actionButton}
onPress={() => void runScan(true)}
/>
<Button
title="Import photo"
variant="secondary"
loading={scanning}
style={styles.actionButton}
onPress={() => void runScan(false)}
/>
</View>
{pendingReceipt ? (
<Text style={{ color: colors.success, fontSize: 13 }}>
Receipt image ready it will attach when you save.
</Text>
) : null}
{receiptSplit ? (
<ReceiptItemSelector
items={receiptSplit.items}
subtotal={receiptSplit.subtotal}
tax={receiptSplit.tax}
total={receiptSplit.total}
onApply={(selection) => {
setForm((current) => ({
...current,
amountText: selection.owedTotal.toFixed(2),
notes: mergeNotes(selection.notes, current.notes),
}));
}}
/>
) : null}
<ExpenseFormFields
value={{
...form,
businessId: form.businessId || defaultBusinessId,
}}
businesses={businesses}
clients={clients}
onChange={setForm}
notesLabel="Notes"
notesPlaceholder="Receipt OCR text or internal notes"
/>
<Button
title="Save expense"
loading={createExpense.isPending}
onPress={() => void handleSave()}
/>
<Button
title="Cancel"
variant="secondary"
onPress={() => router.back()}
/>
</ScrollView>
</TabPage>
</AppBackground>
);
}
const styles = StyleSheet.create({
body: {
padding: spacing.lg,
gap: spacing.md,
},
actions: {
flexDirection: "row",
gap: spacing.sm,
},
actionButton: {
flex: 1,
},
});
function mergeNotes(prefix: string, existing: string) {
const trimmed = existing.trim();
if (!trimmed) return prefix;
if (trimmed.startsWith("Receipt split")) {
const ocrStart = trimmed.indexOf("\n\nOCR text:");
return ocrStart >= 0 ? `${prefix}${trimmed.slice(ocrStart)}` : prefix;
}
return `${prefix}\n\nOCR text:\n${trimmed}`;
}