diff --git a/app.json b/app.json
index 33bada8..318d2ec 100644
--- a/app.json
+++ b/app.json
@@ -10,12 +10,14 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.beenvoice.app",
- "buildNumber": "11",
+ "buildNumber": "12",
"icon": "./assets/beenvoice.icon",
"infoPlist": {
"ITSAppUsesNonExemptEncryption": false,
"NSFaceIDUsageDescription": "Unlock beenvoice with Face ID when returning to the app.",
- "NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice."
+ "NSUserNotificationsUsageDescription": "beenvoice sends reminders when it's time to send an invoice.",
+ "NSCameraUsageDescription": "beenvoice uses the camera to scan expense receipts.",
+ "NSPhotoLibraryUsageDescription": "beenvoice imports receipt photos for expense tracking."
}
},
"android": {
@@ -28,7 +30,8 @@
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.USE_BIOMETRIC",
- "android.permission.USE_FINGERPRINT"
+ "android.permission.USE_FINGERPRINT",
+ "android.permission.CAMERA"
]
},
"web": {
@@ -82,7 +85,20 @@
"@react-native-community/datetimepicker",
"./plugins/withAppIntents.js",
"./plugins/withAppStoreSigning.js",
- "expo-sharing"
+ "expo-sharing",
+ [
+ "expo-image-picker",
+ {
+ "photosPermission": "beenvoice imports receipt photos for expense tracking.",
+ "cameraPermission": "beenvoice uses the camera to scan expense receipts."
+ }
+ ],
+ [
+ "expo-mlkit-ocr",
+ {
+ "iosEngine": "auto"
+ }
+ ]
],
"experiments": {
"typedRoutes": true
diff --git a/app/(app)/more/_layout.tsx b/app/(app)/more/_layout.tsx
new file mode 100644
index 0000000..d1356b1
--- /dev/null
+++ b/app/(app)/more/_layout.tsx
@@ -0,0 +1,12 @@
+import { Stack } from "expo-router";
+
+export default function MoreLayout() {
+ return (
+
+ );
+}
diff --git a/app/(app)/more/expenses/[id].tsx b/app/(app)/more/expenses/[id].tsx
new file mode 100644
index 0000000..a30e5ab
--- /dev/null
+++ b/app/(app)/more/expenses/[id].tsx
@@ -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(
+ null,
+ );
+ const [form, setForm] = useState({
+ 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 ;
+ }
+
+ if (!expense) {
+ return (
+
+
+
+ Expense not found
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {editing ? (
+ <>
+ {receiptSplit ? (
+ {
+ setForm((current) => ({
+ ...current,
+ amountText: selection.owedTotal.toFixed(2),
+ notes: mergeNotes(selection.notes, current.notes),
+ }));
+ }}
+ />
+ ) : null}
+
+
+
+
+
+ );
+}
+
+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 {
+ 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}`;
+}
diff --git a/app/(app)/more/expenses/index.tsx b/app/(app)/more/expenses/index.tsx
new file mode 100644
index 0000000..4f3b876
--- /dev/null
+++ b/app/(app)/more/expenses/index.tsx
@@ -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("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 ;
+ }
+
+ if (expensesQuery.error) {
+ return (
+
+
+
+
+ Could not load expenses
+
+
+ {formatTrpcErrorMessage(expensesQuery.error)}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ router.push("/(app)/more/expenses/new" as never)}
+ />
+ >
+ }
+ refreshControl={
+ void expensesQuery.refetch()}
+ tintColor={colors.primary}
+ />
+ }
+ >
+ {expenses.length === 0 ? (
+
+ No expenses yet. Add one with a receipt photo or manual entry.
+
+ ) : (
+ <>
+
+
+
+ Visible total
+
+
+ {formatCurrency(total)}
+
+
+
+
+ Receipts
+
+
+ {receiptCount}
+
+
+
+
+
+ setFilter("all")}
+ />
+ setFilter("billable")}
+ />
+ setFilter("receipts")}
+ />
+
+
+ {filteredExpenses.length === 0 ? (
+
+ No expenses match this filter.
+
+ ) : (
+ filteredExpenses.map((expense) => (
+
+ 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 }),
+ },
+ ]}
+ >
+
+ router.push(
+ `/(app)/more/expenses/${expense.id}` as never,
+ )
+ }
+ style={({ pressed }) => [
+ styles.row,
+ pressed && styles.rowPressed,
+ ]}
+ >
+
+
+
+ {expense.description}
+
+ {expense.receiptCount ? (
+
+ {expense.receiptCount}
+
+ ) : null}
+
+
+ {formatDate(expense.date)}
+ {expense.category ? ` · ${expense.category}` : ""}
+ {expense.client?.name
+ ? ` · ${expense.client.name}`
+ : ""}
+ {expense.billable ? " · Billable" : ""}
+
+
+
+ {formatCurrency(expense.amount, expense.currency)}
+
+
+
+ ))
+ )}
+ >
+ )}
+
+
+
+ );
+}
+
+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,
+ },
+ });
diff --git a/app/(app)/more/expenses/new.tsx b/app/(app)/more/expenses/new.tsx
new file mode 100644
index 0000000..5faf2e2
--- /dev/null
+++ b/app/(app)/more/expenses/new.tsx
@@ -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(() =>
+ defaultExpenseFormState(),
+ );
+ const [scanning, setScanning] = useState(false);
+ const [pendingReceipt, setPendingReceipt] =
+ useState(null);
+ const [receiptSplit, setReceiptSplit] = useState(
+ 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 (
+
+
+
+
+
+
+ void runScan(true)}
+ />
+ void runScan(false)}
+ />
+
+
+ {pendingReceipt ? (
+
+ Receipt image ready — it will attach when you save.
+
+ ) : null}
+
+ {receiptSplit ? (
+ {
+ setForm((current) => ({
+ ...current,
+ amountText: selection.owedTotal.toFixed(2),
+ notes: mergeNotes(selection.notes, current.notes),
+ }));
+ }}
+ />
+ ) : null}
+
+
+
+ void handleSave()}
+ />
+ router.back()}
+ />
+
+
+
+ );
+}
+
+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}`;
+}
diff --git a/app/(app)/more/index.tsx b/app/(app)/more/index.tsx
new file mode 100644
index 0000000..92461a0
--- /dev/null
+++ b/app/(app)/more/index.tsx
@@ -0,0 +1,119 @@
+import { Ionicons } from "@expo/vector-icons";
+import { router } from "expo-router";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+import { AppBackground } from "@/components/AppBackground";
+import { PageHeader } from "@/components/PageHeader";
+import { TabPage } from "@/components/TabPage";
+import { TabScrollView } from "@/components/TabScrollView";
+import { fonts, radii, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import type { ThemeColors } from "@/lib/theme-palette";
+import { useThemedStyles } from "@/lib/use-themed-styles";
+
+type HubItem = {
+ title: string;
+ subtitle: string;
+ href: string;
+ icon: keyof typeof Ionicons.glyphMap;
+};
+
+const ITEMS: HubItem[] = [
+ {
+ title: "Expenses",
+ subtitle: "Track costs and attach receipts",
+ href: "/(app)/more/expenses",
+ icon: "receipt-outline",
+ },
+ {
+ title: "Reports",
+ subtitle: "Revenue, hours, and tax summaries",
+ href: "/(app)/more/reports",
+ icon: "bar-chart-outline",
+ },
+ {
+ title: "Recurring invoices",
+ subtitle: "Scheduled billing templates",
+ href: "/(app)/more/recurring",
+ icon: "repeat-outline",
+ },
+ {
+ title: "Time entries",
+ subtitle: "Full history with edit and delete",
+ href: "/(app)/more/time-entries",
+ icon: "time-outline",
+ },
+];
+
+export default function MoreScreen() {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(createStyles);
+
+ return (
+
+
+ }>
+
+ {ITEMS.map((item) => (
+ [styles.row, pressed && styles.rowPressed]}
+ onPress={() => router.push(item.href as never)}
+ >
+
+
+
+
+ {item.title}
+
+ {item.subtitle}
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+const createStyles = (colors: ThemeColors) =>
+ StyleSheet.create({
+ list: {
+ gap: spacing.sm,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.md,
+ padding: spacing.md,
+ borderRadius: radii.lg,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.card,
+ },
+ rowPressed: {
+ opacity: 0.85,
+ },
+ iconWrap: {
+ width: 44,
+ height: 44,
+ borderRadius: radii.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ copy: {
+ flex: 1,
+ gap: 2,
+ },
+ title: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 16,
+ },
+ subtitle: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ },
+ });
diff --git a/app/(app)/more/recurring.tsx b/app/(app)/more/recurring.tsx
new file mode 100644
index 0000000..bf72104
--- /dev/null
+++ b/app/(app)/more/recurring.tsx
@@ -0,0 +1,124 @@
+import { RefreshControl, StyleSheet, Text, View } from "react-native";
+
+import { AppBackground } from "@/components/AppBackground";
+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 { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { formatCurrency, formatDate } from "@/lib/format";
+import { api } from "@/lib/trpc";
+
+export default function RecurringScreen() {
+ const { colors } = useAppTheme();
+ const utils = api.useUtils();
+ const query = api.recurringInvoices.getAll.useQuery();
+
+ const pause = api.recurringInvoices.pause.useMutation({
+ onSuccess: () => void query.refetch(),
+ });
+ const resume = api.recurringInvoices.resume.useMutation({
+ onSuccess: () => void query.refetch(),
+ });
+ const generateNow = api.recurringInvoices.generateNow.useMutation({
+ onSuccess: () => {
+ void utils.invoices.getAll.invalidate();
+ void query.refetch();
+ },
+ });
+
+ if (query.isLoading) {
+ return ;
+ }
+
+ const items = query.data ?? [];
+
+ return (
+
+
+
+ }
+ refreshControl={
+ void query.refetch()}
+ tintColor={colors.primary}
+ />
+ }
+ >
+ {items.length === 0 ? (
+
+ No recurring invoices yet. Create them on the web dashboard for now.
+
+ ) : (
+ items.map((item) => (
+ generateNow.mutate({ id: item.id }),
+ },
+ {
+ key: "toggle",
+ label: item.status === "active" ? "Pause" : "Resume",
+ icon: item.status === "active" ? "pause-outline" : "play-outline",
+ color: "#fff",
+ backgroundColor: colors.mutedForeground,
+ onPress: () =>
+ item.status === "active"
+ ? pause.mutate({ id: item.id })
+ : resume.mutate({ id: item.id }),
+ },
+ ]}
+ >
+
+
+ {item.name}
+
+ {item.client?.name ?? "Client"} · {item.schedule} · {item.status}
+
+
+ Next due {formatDate(item.nextDueAt)}
+
+
+
+ {formatCurrency(
+ item.items.reduce((sum, line) => sum + line.hours * line.rate, 0),
+ item.currency,
+ )}
+
+
+
+ ))
+ )}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ gap: spacing.md,
+ paddingVertical: spacing.md,
+ borderBottomWidth: 1,
+ borderBottomColor: "rgba(0,0,0,0.08)",
+ },
+ title: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ },
+});
diff --git a/app/(app)/more/reports.tsx b/app/(app)/more/reports.tsx
new file mode 100644
index 0000000..1ebb70c
--- /dev/null
+++ b/app/(app)/more/reports.tsx
@@ -0,0 +1,108 @@
+import { RefreshControl, StyleSheet, Text, View } from "react-native";
+
+import { AppBackground } from "@/components/AppBackground";
+import { LoadingScreen } from "@/components/LoadingScreen";
+import { PageHeader } from "@/components/PageHeader";
+import { StatCard } from "@/components/StatCard";
+import { TabPage } from "@/components/TabPage";
+import { TabScrollView } from "@/components/TabScrollView";
+import { Card } from "@/components/ui/Card";
+import { spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { formatCurrency } from "@/lib/format";
+import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
+import { api } from "@/lib/trpc";
+
+export default function ReportsScreen() {
+ const { colors } = useAppTheme();
+ const statsQuery = api.dashboard.getStats.useQuery();
+ const expensesQuery = api.expenses.getAll.useQuery();
+ const summaryQuery = api.timeEntries.getSummary.useQuery();
+
+ if (statsQuery.isLoading || expensesQuery.isLoading || summaryQuery.isLoading) {
+ return ;
+ }
+
+ if (statsQuery.error) {
+ return (
+
+
+
+ {formatTrpcErrorMessage(statsQuery.error)}
+
+
+
+ );
+ }
+
+ const stats = statsQuery.data!;
+ const expenseTotal = (expensesQuery.data ?? []).reduce((sum, e) => sum + e.amount, 0);
+ const summary = summaryQuery.data;
+
+ return (
+
+
+ }
+ refreshControl={
+ {
+ void statsQuery.refetch();
+ void expensesQuery.refetch();
+ void summaryQuery.refetch();
+ }}
+ tintColor={colors.primary}
+ />
+ }
+ >
+
+
+
+
+
+
+
+
+ {(stats.statusChartData ?? []).map((item) => (
+
+ {item.name}
+
+ {item.count} · {formatCurrency(item.value)}
+
+
+ ))}
+
+
+
+ {stats.revenueChartData.map((point) => (
+
+ {point.monthLabel}
+
+ {formatCurrency(point.revenue)}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ grid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: spacing.md,
+ },
+ statusRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ paddingVertical: spacing.sm,
+ },
+});
diff --git a/app/(app)/more/time-entries.tsx b/app/(app)/more/time-entries.tsx
new file mode 100644
index 0000000..fd88891
--- /dev/null
+++ b/app/(app)/more/time-entries.tsx
@@ -0,0 +1,145 @@
+import { useMemo, useState } from "react";
+import { RefreshControl, StyleSheet, Text, View } from "react-native";
+
+import { AppBackground } from "@/components/AppBackground";
+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 { TimeEntryEditSheet } from "@/components/time-clock/TimeEntryEditSheet";
+import { Card } from "@/components/ui/Card";
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { formatRunningTimerLabel } from "@/lib/time-clock";
+import { formatTrpcErrorMessage } from "@/lib/trpc-errors";
+import { api } from "@/lib/trpc";
+import type { AppRouter } from "beenvoice/server/api/root";
+import type { inferRouterOutputs } from "@trpc/server";
+
+type TimeEntry = inferRouterOutputs["timeEntries"]["getAll"][number];
+
+function groupByDate(entries: TimeEntry[]) {
+ const groups = new Map();
+ for (const entry of entries) {
+ const d = new Date(entry.startedAt);
+ const key = d.toLocaleDateString(undefined, {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+ const list = groups.get(key) ?? [];
+ list.push(entry);
+ groups.set(key, list);
+ }
+ return Array.from(groups.entries());
+}
+
+export default function TimeEntriesScreen() {
+ const { colors } = useAppTheme();
+ const [editEntryId, setEditEntryId] = useState(null);
+ const entriesQuery = api.timeEntries.getAll.useQuery();
+
+ const completed = useMemo(
+ () => (entriesQuery.data ?? []).filter((entry) => entry.endedAt),
+ [entriesQuery.data],
+ );
+ const grouped = useMemo(() => groupByDate(completed), [completed]);
+
+ if (entriesQuery.isLoading) {
+ return ;
+ }
+
+ if (entriesQuery.error) {
+ return (
+
+
+
+ {formatTrpcErrorMessage(entriesQuery.error)}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ refreshControl={
+ void entriesQuery.refetch()}
+ tintColor={colors.primary}
+ />
+ }
+ >
+ {grouped.length === 0 ? (
+
+ No completed entries yet. Start the timer from the Timer tab.
+
+ ) : (
+ grouped.map(([label, entries]) => (
+
+ {entries.map((entry) => (
+ setEditEntryId(entry.id),
+ },
+ ]}
+ >
+
+
+
+ {formatRunningTimerLabel(entry.description)}
+
+
+ {entry.client?.name ?? "No client"}
+ {entry.invoice
+ ? ` · ${entry.invoice.invoicePrefix ?? "#"}${entry.invoice.invoiceNumber}`
+ : " · not billed"}
+
+
+
+ {entry.hours ?? "—"}h
+
+
+
+ ))}
+
+ ))
+ )}
+
+
+
+ setEditEntryId(null)}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: "row",
+ gap: spacing.md,
+ paddingVertical: spacing.sm,
+ },
+ title: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 14,
+ },
+});
diff --git a/bun.lock b/bun.lock
index 08d2bad..7143c86 100644
--- a/bun.lock
+++ b/bun.lock
@@ -25,9 +25,11 @@
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.7",
"expo-image": "^56.0.11",
+ "expo-image-picker": "~56.0.18",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4",
+ "expo-mlkit-ocr": "^0.2.7",
"expo-network": "^56.0.5",
"expo-notifications": "^56.0.18",
"expo-router": "~56.2.11",
@@ -696,6 +698,10 @@
"expo-image": ["expo-image@56.0.11", "", { "dependencies": { "sf-symbols-typescript": "^2.2.0" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*", "react-native-web": "*" }, "optionalPeers": ["react-native-web"] }, "sha512-k2xwxGk14xi6zxmEGAU4rUTb1lK5qf0y0Qb8+Jaggnul0KaJJxcq9qvyDp9iyJBW35cp9isONAUnNtIiooZ/Pw=="],
+ "expo-image-loader": ["expo-image-loader@56.0.3", "", { "peerDependencies": { "expo": "*" } }, "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA=="],
+
+ "expo-image-picker": ["expo-image-picker@56.0.18", "", { "dependencies": { "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg=="],
+
"expo-json-utils": ["expo-json-utils@56.0.0", "", {}, "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg=="],
"expo-keep-awake": ["expo-keep-awake@56.0.3", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA=="],
@@ -708,6 +714,8 @@
"expo-manifests": ["expo-manifests@56.0.4", "", { "dependencies": { "expo-json-utils": "~56.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw=="],
+ "expo-mlkit-ocr": ["expo-mlkit-ocr@0.2.7", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-zx+6rZPwfzGc2ck+tKLFiHwkezbFJqt+Xrz3P5Q0dUAxgIIwwIXtXCjMReRQitnC/vfBGLgh26gO2slEGXSuLQ=="],
+
"expo-modules-autolinking": ["expo-modules-autolinking@56.0.16", "", { "dependencies": { "@expo/require-utils": "^56.1.3", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" }, "bin": "bin/expo-modules-autolinking.js" }, "sha512-9JnL4N46P8ubDpDIfWolDn7nxU2j1rY67xY/dNVuyH0m+HG+r/JI16VYtjIf4COpZtEuFo4D3h3MBeFzGucMnw=="],
"expo-modules-core": ["expo-modules-core@56.0.17", "", { "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", "expo-modules-jsi": "~56.0.10", "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*", "react-native-worklets": "^0.7.4 || ^0.8.0" } }, "sha512-5J8whnT7Ccp+BrFClLmpF76omBqn95VZExroTm01Dgjm4vpty1Rb7U3we+ZUceNHtRd07Lw30u7FNfDgIhEbRQ=="],
diff --git a/components/SwipeableRow.tsx b/components/SwipeableRow.tsx
new file mode 100644
index 0000000..042dbfb
--- /dev/null
+++ b/components/SwipeableRow.tsx
@@ -0,0 +1,92 @@
+import { Ionicons } from "@expo/vector-icons";
+import { ReactNode, useRef } from "react";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import Swipeable, {
+ type SwipeableMethods,
+} from "react-native-gesture-handler/ReanimatedSwipeable";
+
+import { fonts, radii, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import type { ThemeColors } from "@/lib/theme-palette";
+import { useThemedStyles } from "@/lib/use-themed-styles";
+
+export type SwipeAction = {
+ key: string;
+ label: string;
+ icon: keyof typeof Ionicons.glyphMap;
+ color: string;
+ backgroundColor: string;
+ onPress: () => void;
+};
+
+type SwipeableRowProps = {
+ children: ReactNode;
+ actions: SwipeAction[];
+ enabled?: boolean;
+ backgroundColor?: string;
+};
+
+export function SwipeableRow({
+ children,
+ actions,
+ enabled = true,
+ backgroundColor,
+}: SwipeableRowProps) {
+ const { colors } = useAppTheme();
+ const styles = useThemedStyles(createSwipeableRowStyles);
+ const rowBackground = backgroundColor ?? colors.background;
+ const swipeRef = useRef(null);
+
+ function renderRightActions() {
+ return (
+
+ {actions.map((action) => (
+ {
+ swipeRef.current?.close();
+ action.onPress();
+ }}
+ >
+
+ {action.label}
+
+ ))}
+
+ );
+ }
+
+ if (!enabled || actions.length === 0) {
+ return {children};
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+const createSwipeableRowStyles = (colors: ThemeColors) =>
+ StyleSheet.create({
+ row: {
+ backgroundColor: colors.background,
+ },
+ actions: {
+ flexDirection: "row",
+ alignItems: "stretch",
+ },
+ actionButton: {
+ width: 80,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: spacing.xs,
+ borderRadius: radii.md,
+ marginLeft: spacing.xs,
+ },
+ actionLabel: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 11,
+ },
+ });
diff --git a/components/expenses/ExpenseFormFields.tsx b/components/expenses/ExpenseFormFields.tsx
new file mode 100644
index 0000000..8bfda5c
--- /dev/null
+++ b/components/expenses/ExpenseFormFields.tsx
@@ -0,0 +1,219 @@
+import { Switch, StyleSheet, Text, View } from "react-native";
+
+import { DateTimeField } from "@/components/ui/DateTimeField";
+import { Input } from "@/components/ui/Input";
+import { SelectField, type SelectOption } from "@/components/ui/SelectField";
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { EXPENSE_CATEGORIES } from "@/lib/expense-categories";
+
+const NONE = "__none__";
+
+export type ExpenseFormState = {
+ description: string;
+ amountText: string;
+ date: Date;
+ category: string;
+ businessId: string;
+ clientId: string;
+ billable: boolean;
+ reimbursable: boolean;
+ taxDeductible: boolean;
+ notes: string;
+};
+
+type ExpenseFormFieldsProps = {
+ value: ExpenseFormState;
+ businesses: Array<{ id: string; name: string; isDefault?: boolean | null }>;
+ clients: Array<{ id: string; name: string }>;
+ onChange: (value: ExpenseFormState) => void;
+ notesLabel?: string;
+ notesPlaceholder?: string;
+};
+
+export function defaultExpenseFormState(
+ defaultBusinessId = "",
+): ExpenseFormState {
+ return {
+ description: "",
+ amountText: "",
+ date: new Date(),
+ category: "",
+ businessId: defaultBusinessId,
+ clientId: "",
+ billable: false,
+ reimbursable: false,
+ taxDeductible: false,
+ notes: "",
+ };
+}
+
+export function ExpenseFormFields({
+ value,
+ businesses,
+ clients,
+ onChange,
+ notesLabel = "Notes",
+ notesPlaceholder = "Internal details or receipt OCR text",
+}: ExpenseFormFieldsProps) {
+ const { colors } = useAppTheme();
+
+ const businessOptions: SelectOption[] = [
+ { label: "Default business", value: NONE },
+ ...businesses.map((business) => ({
+ label: business.isDefault ? `${business.name} (default)` : business.name,
+ value: business.id,
+ })),
+ ];
+ const clientOptions: SelectOption[] = [
+ { label: "No client", value: NONE },
+ ...clients.map((client) => ({ label: client.name, value: client.id })),
+ ];
+ const categoryOptions: SelectOption[] = [
+ { label: "No category", value: NONE },
+ ...EXPENSE_CATEGORIES.map((category) => ({
+ label: category,
+ value: category,
+ })),
+ ];
+
+ const setField = (
+ field: K,
+ nextValue: ExpenseFormState[K],
+ ) => onChange({ ...value, [field]: nextValue });
+
+ return (
+
+ setField("description", text)}
+ placeholder="e.g. Client lunch"
+ />
+ setField("amountText", text)}
+ keyboardType="decimal-pad"
+ placeholder="0.00"
+ />
+ setField("date", date)}
+ mode="date"
+ />
+
+ setField("category", next === NONE ? "" : next)
+ }
+ />
+
+ setField("businessId", next === NONE ? "" : next)
+ }
+ />
+
+ setField("clientId", next === NONE ? "" : next)
+ }
+ />
+
+
+ setField("billable", next)}
+ />
+ setField("reimbursable", next)}
+ />
+ setField("taxDeductible", next)}
+ />
+
+
+ setField("notes", text)}
+ placeholder={notesPlaceholder}
+ multiline
+ style={styles.notesInput}
+ />
+
+ );
+
+ function FlagSwitch({
+ label,
+ value: checked,
+ onValueChange,
+ }: {
+ label: string;
+ value: boolean;
+ onValueChange: (value: boolean) => void;
+ }) {
+ return (
+
+
+ {label}
+
+
+
+ );
+ }
+}
+
+const styles = StyleSheet.create({
+ stack: {
+ gap: spacing.md,
+ },
+ flags: {
+ gap: spacing.sm,
+ },
+ flagRow: {
+ minHeight: 48,
+ borderWidth: 1,
+ borderRadius: 12,
+ paddingHorizontal: spacing.md,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ flagLabel: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 14,
+ },
+ notesInput: {
+ minHeight: 96,
+ textAlignVertical: "top",
+ paddingTop: spacing.md,
+ },
+});
diff --git a/components/expenses/ReceiptItemSelector.tsx b/components/expenses/ReceiptItemSelector.tsx
new file mode 100644
index 0000000..bf6a568
--- /dev/null
+++ b/components/expenses/ReceiptItemSelector.tsx
@@ -0,0 +1,265 @@
+import { useMemo, useState } from "react";
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+
+import { Button } from "@/components/ui/Button";
+import { fonts, spacing } from "@/constants/theme";
+import { useAppTheme } from "@/contexts/ThemeContext";
+import { formatCurrency } from "@/lib/format";
+import type { ReceiptLineItem } from "@/lib/receipt-parse";
+
+export type ReceiptSplitSelection = {
+ selectedItemIds: string[];
+ selectedSubtotal: number;
+ allocatedTax: number;
+ owedTotal: number;
+ notes: string;
+};
+
+type ReceiptItemSelectorProps = {
+ items: ReceiptLineItem[];
+ subtotal: number | null;
+ tax: number | null;
+ total: number | null;
+ onApply: (selection: ReceiptSplitSelection) => void;
+};
+
+export function ReceiptItemSelector({
+ items,
+ subtotal,
+ tax,
+ total,
+ onApply,
+}: ReceiptItemSelectorProps) {
+ const { colors } = useAppTheme();
+ const [selectedIds, setSelectedIds] = useState>(
+ () => new Set(items.map((item) => item.id)),
+ );
+
+ const calculation = useMemo(() => {
+ const selected = items.filter((item) => selectedIds.has(item.id));
+ const selectedSubtotal = roundMoney(
+ selected.reduce((sum, item) => sum + item.amount, 0),
+ );
+ const receiptSubtotal =
+ subtotal && subtotal > 0
+ ? subtotal
+ : roundMoney(items.reduce((sum, item) => sum + item.amount, 0));
+ const knownTax =
+ tax ??
+ (total && receiptSubtotal > 0
+ ? Math.max(0, roundMoney(total - receiptSubtotal))
+ : 0);
+ const allocatedTax =
+ receiptSubtotal > 0
+ ? roundMoney(knownTax * (selectedSubtotal / receiptSubtotal))
+ : 0;
+ const owedTotal = roundMoney(selectedSubtotal + allocatedTax);
+ const notes = [
+ "Receipt split",
+ ...selected.map(
+ (item) => `- ${item.name}: ${formatCurrency(item.amount)}`,
+ ),
+ `Selected subtotal: ${formatCurrency(selectedSubtotal)}`,
+ `Allocated tax: ${formatCurrency(allocatedTax)}`,
+ `Owed total: ${formatCurrency(owedTotal)}`,
+ ].join("\n");
+
+ return { selected, selectedSubtotal, allocatedTax, owedTotal, notes };
+ }, [items, selectedIds, subtotal, tax, total]);
+
+ if (items.length === 0) return null;
+
+ const toggle = (id: string) => {
+ setSelectedIds((current) => {
+ const next = new Set(current);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ return (
+
+
+
+
+ Split receipt items
+
+
+ Select what this person owes. Tax is split proportionally.
+
+
+
+ {formatCurrency(calculation.owedTotal)}
+
+
+
+
+ {items.map((item) => {
+ const selected = selectedIds.has(item.id);
+ return (
+ toggle(item.id)}
+ style={({ pressed }) => [
+ styles.item,
+ { borderColor: colors.borderGlass },
+ selected && { backgroundColor: colors.muted },
+ pressed && styles.pressed,
+ ]}
+ >
+
+
+ {item.name}
+
+
+ {formatCurrency(item.amount)}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ onApply({
+ selectedItemIds: calculation.selected.map((item) => item.id),
+ selectedSubtotal: calculation.selectedSubtotal,
+ allocatedTax: calculation.allocatedTax,
+ owedTotal: calculation.owedTotal,
+ notes: calculation.notes,
+ })
+ }
+ />
+
+ );
+
+ function SummaryRow({
+ label,
+ value,
+ strong,
+ }: {
+ label: string;
+ value: number;
+ strong?: boolean;
+ }) {
+ return (
+
+
+ {label}
+
+
+ {formatCurrency(value)}
+
+
+ );
+ }
+}
+
+function roundMoney(value: number) {
+ return Math.round((value + Number.EPSILON) * 100) / 100;
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ gap: spacing.md,
+ borderWidth: 1,
+ borderRadius: 16,
+ padding: spacing.md,
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ gap: spacing.md,
+ },
+ headerCopy: {
+ flex: 1,
+ gap: 2,
+ },
+ title: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 16,
+ },
+ subtitle: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ lineHeight: 18,
+ },
+ total: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 18,
+ fontVariant: ["tabular-nums"],
+ },
+ itemList: {
+ gap: spacing.sm,
+ },
+ item: {
+ minHeight: 48,
+ borderWidth: 1,
+ borderRadius: 12,
+ paddingHorizontal: spacing.sm,
+ paddingVertical: spacing.sm,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing.sm,
+ },
+ pressed: {
+ opacity: 0.85,
+ },
+ itemName: {
+ flex: 1,
+ fontFamily: fonts.bodyMedium,
+ fontSize: 14,
+ },
+ itemAmount: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 14,
+ fontVariant: ["tabular-nums"],
+ },
+ summary: {
+ gap: spacing.xs,
+ },
+ summaryRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ gap: spacing.md,
+ },
+ summaryLabel: {
+ fontFamily: fonts.body,
+ fontSize: 13,
+ },
+ summaryValue: {
+ fontFamily: fonts.bodyMedium,
+ fontSize: 13,
+ fontVariant: ["tabular-nums"],
+ },
+ summaryValueStrong: {
+ fontFamily: fonts.bodySemiBold,
+ fontSize: 15,
+ },
+});
diff --git a/lib/expense-categories.ts b/lib/expense-categories.ts
new file mode 100644
index 0000000..85c1833
--- /dev/null
+++ b/lib/expense-categories.ts
@@ -0,0 +1,11 @@
+export const EXPENSE_CATEGORIES = [
+ "Travel",
+ "Meals & Entertainment",
+ "Software & Subscriptions",
+ "Hardware & Equipment",
+ "Office Supplies",
+ "Marketing",
+ "Professional Services",
+ "Utilities",
+ "Other",
+] as const;
diff --git a/lib/receipt-ocr.ts b/lib/receipt-ocr.ts
new file mode 100644
index 0000000..3ad1f87
--- /dev/null
+++ b/lib/receipt-ocr.ts
@@ -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 {
+ 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"),
+ };
+}
diff --git a/lib/receipt-parse.ts b/lib/receipt-parse.ts
new file mode 100644
index 0000000..e66115d
--- /dev/null
+++ b/lib/receipt-parse.ts
@@ -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,
+ };
+}
diff --git a/lib/receipt-scan.ts b/lib/receipt-scan.ts
new file mode 100644
index 0000000..234e525
--- /dev/null
+++ b/lib/receipt-scan.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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;
+ }
+}
diff --git a/package.json b/package.json
index c41c8e0..6f700d3 100644
--- a/package.json
+++ b/package.json
@@ -23,9 +23,11 @@
"expo-file-system": "~56.0.8",
"expo-font": "~56.0.7",
"expo-image": "^56.0.11",
+ "expo-image-picker": "~56.0.18",
"expo-linear-gradient": "~56.0.4",
"expo-linking": "~56.0.14",
"expo-local-authentication": "~56.0.4",
+ "expo-mlkit-ocr": "^0.2.7",
"expo-network": "^56.0.5",
"expo-notifications": "^56.0.18",
"expo-router": "~56.2.11",