Fotografiere deinen Kassenbon. Du kontrollierst jeden erkannten Wert, bevor etwas gespeichert wird.
-
-
{file?.name} Bereit zur Analyse
+
{file?.name} {config?.extractionMode === "manual" ? "Bereit zur Erfassung" : "Bereit zur Analyse"}
) : (
@@ -300,29 +433,35 @@ function CaptureScreen({
)}
- cameraRef.current?.click()}> Foto aufnehmen
- galleryRef.current?.click()}> Bild aus Galerie wählen
+ nativePlatform ? onPickNative("camera") : cameraRef.current?.click()}> Foto aufnehmen
+ nativePlatform ? onPickNative("gallery") : galleryRef.current?.click()}> Bild aus Galerie wählen
+ {selectingImage &&
Bonbild wird lokal übernommen …
}
+ {imageError &&
}
{previewUrl && (
-
Vor dem Analysieren {config?.disclosure ?? "Konfiguration wird geladen …"}
-
Bon jetzt analysieren
+
{config?.extractionMode === "manual" ? "Vor dem Erfassen" : "Vor dem Analysieren"} {config?.disclosure ?? "Konfiguration wird geladen …"}
+
{config?.extractionMode === "manual" ? "Bild übernehmen" : "Bon jetzt analysieren"}
)}
-
ChatGPT-JSON importieren
+
ChatGPT-JSON importieren
Ohne Bild manuell erfassen
Erst nach „Bon jetzt analysieren“ wird ein Bild verarbeitet.
);
}
-function JsonImportScreen({ file, previewUrl, onChoose, onBack, onImport }: {
+function JsonImportScreen({ file, previewUrl, selectingImage, imageError, onChoose, onPickNative, nativePlatform, onBack, onImport }: {
file: File | null;
previewUrl: string | null;
+ selectingImage: boolean;
+ imageError: string | null;
onChoose: (file: File | null) => void;
+ onPickNative: (source: "camera" | "gallery") => void;
+ nativePlatform: boolean;
onBack: () => void;
onImport: (content: string) => Promise
;
}) {
@@ -362,7 +501,7 @@ function JsonImportScreen({ file, previewUrl, onChoose, onBack, onImport }: {
return (
- ← Zurück
+ ← Zurück
Ohne API-Schlüssel
ChatGPT-JSON importieren
Lass deinen Bon in einem normalen ChatGPT-Chat auslesen und füge das Ergebnis hier ein. Itemly prüft die Struktur, bevor du jeden Wert kontrollierst.
@@ -385,9 +524,10 @@ function JsonImportScreen({ file, previewUrl, onChoose, onBack, onImport }: {
@@ -403,7 +543,7 @@ function JsonImportScreen({ file, previewUrl, onChoose, onBack, onImport }: {
/>
{error && }
-
+
{submitting ? "JSON wird geprüft …" : "JSON prüfen und übernehmen"} {!submitting && }
@@ -434,7 +574,10 @@ function ReviewScreen({
onChange,
onNext,
onSaveDraft,
- saving
+ saving,
+ editing,
+ onCancel,
+ focusedItemId
}: {
draft: ReceiptDraft;
imageUrl: string | null;
@@ -442,14 +585,21 @@ function ReviewScreen({
onNext: () => void;
onSaveDraft: () => void;
saving: boolean;
+ editing: boolean;
+ onCancel: () => void;
+ focusedItemId: string | null;
}) {
const [showImage, setShowImage] = useState(false);
- const [expandedId, setExpandedId] = useState(draft.items[0]?.id ?? null);
- const [removed, setRemoved] = useState<{ item: ReceiptItem; index: number } | null>(null);
+ const [expandedId, setExpandedId] = useState(focusedItemId ?? draft.items[0]?.id ?? null);
+ const [removed, setRemoved] = useState<{ item: ReceiptItem; index: number; snapshot: ReceiptDraft } | null>(null);
const calculations = useMemo(() => calculateReceipt(draft), [draft]);
function field(key: K, value: ReceiptDraft[K]) {
- onChange({ ...draft, [key]: value, fieldSources: { ...draft.fieldSources, [key]: "user_entered" } });
+ if (key === "currency") {
+ onChange(confirmCurrency(draft, value as string));
+ } else {
+ onChange({ ...draft, [key]: value, fieldSources: { ...draft.fieldSources, [key]: "user_entered" } });
+ }
}
function updateItem(id: string, item: ReceiptItem) {
@@ -459,8 +609,8 @@ function ReviewScreen({
function removeItem(id: string) {
const index = draft.items.findIndex((item) => item.id === id);
if (index < 0) return;
- setRemoved({ item: draft.items[index], index });
- onChange({ ...draft, items: draft.items.filter((item) => item.id !== id) });
+ setRemoved({ item: draft.items[index], index, snapshot: draft });
+ onChange(replaceReceiptItems(draft, draft.items.filter((item) => item.id !== id)));
setExpandedId(null);
}
@@ -468,7 +618,7 @@ function ReviewScreen({
if (!removed) return;
const next = [...draft.items];
next.splice(removed.index, 0, removed.item);
- onChange({ ...draft, items: next });
+ onChange(replaceReceiptItems(draft, next, removed.snapshot));
setRemoved(null);
}
@@ -479,18 +629,21 @@ function ReviewScreen({
}
return (
-
+
-
Ergebnis kontrollieren
Einkauf prüfen
+
{editing ? "Gespeicherten Beleg ändern" : "Ergebnis kontrollieren"}
{editing ? "Beleg bearbeiten" : "Einkauf prüfen"}
{imageUrl &&
setShowImage(!showImage)}> {showImage ? "Bon ausblenden" : "Originalbon"} }
-
+ {editing && Du bearbeitest den bestehenden Beleg. Originalbild und ursprüngliche Auswertung bleiben erhalten.
Bearbeitung abbrechen }
+
+
{showImage && imageUrl && }
Bonangaben Tippe in ein Feld, um es zu korrigieren.
+ field("currency", currency)} />
field("merchantName", value)} />
field("storeName", value)} />
field("addressText", value)} />
@@ -498,7 +651,6 @@ function ReviewScreen({
field("purchasedTime", value)} />
field("receiptNumber", value)} />
field("transactionId", value)} />
- Währung field("currency", event.target.value.toUpperCase())}>CAD EUR USD GBP
@@ -609,31 +761,34 @@ function ItemCard({ item, index, currency, expanded, onToggle, onChange, onRemov
);
}
-function SaveScreen({ draft, imageUrl, onBack, onSave, saving }: {
+function SaveScreen({ draft, imageUrl, onBack, onSave, saving, editing }: {
draft: ReceiptDraft;
imageUrl: string | null;
onBack: () => void;
onSave: (status: "draft" | "confirmed") => void;
saving: boolean;
+ editing: boolean;
}) {
const calculations = calculateReceipt(draft);
+ const currencyNeedsReview = needsCurrencyReview(draft);
return (
Letzte Kontrolle
- Einkauf speichern
- Erst nach erfolgreicher Speicherung erscheint der Einkauf im Verlauf.
+ {editing ? "Änderungen speichern" : "Einkauf speichern"}
+ {editing ? "Der vorhandene Beleg wird aktualisiert – es entsteht kein zweiter Eintrag." : "Erst nach erfolgreicher Speicherung erscheint der Einkauf im Verlauf."}
{imageUrl ?
:
}
{draft.purchasedDate ?? "Datum unbekannt"} {draft.merchantName ?? "Händler unbekannt"} {calculations.positionCount} Positionen · {draft.paymentMethod ?? "Zahlung unbekannt"}
Gesamtbetrag {formatMoney(draft.totalMinor, draft.currency)}
-
+
{!calculations.isBalanced && Noch nicht ausgeglichen Du kannst diesen Einkauf als Entwurf speichern und später ergänzen. Bestätigen ist erst bei übereinstimmenden Beträgen möglich.
}
+ {currencyNeedsReview && Währung prüfen Bitte wähle oder bestätige die Währung in der Prüfansicht. Als Entwurf kannst du den Bon bereits speichern.
}
SQLite-Datensatz Originalbild Portable JSON-Exporte
Zurück zur Prüfung
- {!calculations.isBalanced && onSave("draft")} disabled={saving}>{saving ? "Speichert …" : "Als Entwurf speichern"} }
- onSave("confirmed")} disabled={saving || !calculations.isBalanced}>{saving ? "Speichert …" : "Einkauf speichern"} {!saving && }
+ {(editing || !calculations.isBalanced || currencyNeedsReview) && onSave("draft")} disabled={saving}>{saving ? "Speichert …" : "Als Entwurf speichern"} }
+ onSave("confirmed")} disabled={saving || !calculations.isBalanced || currencyNeedsReview}>{saving ? "Speichert …" : editing ? "Änderungen speichern" : "Einkauf speichern"} {!saving && }
);
@@ -644,33 +799,37 @@ function HistoryScreen({ onOpen, onNew }: { onOpen: (id: string) => void; onNew:
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [trash, setTrash] = useState(false);
+ const [backupRevision, setBackupRevision] = useState(0);
const groups = useMemo(() => groupHistoryEntries(entries), [entries]);
useEffect(() => {
+ let active = true;
+ setLoading(true);
const handle = window.setTimeout(() => {
- setLoading(true);
- loadHistory(search).then((result) => { setEntries(result.receipts); setError(null); }).catch((reason: Error) => setError(reason.message)).finally(() => setLoading(false));
+ loadHistory(search, trash).then((result) => { if (active) { setEntries(result.receipts); setError(null); } }).catch((reason: Error) => { if (active) setError(reason.message); }).finally(() => { if (active) setLoading(false); });
}, 180);
- return () => window.clearTimeout(handle);
- }, [search]);
+ return () => { active = false; window.clearTimeout(handle); };
+ }, [search, trash, backupRevision]);
return (
Übersicht
-
Deine Bons
-
Alle Einkäufe auf einen Blick – die neuesten stehen oben.
+
{trash ? "Papierkorb" : "Deine Bons"}
+
{trash ? "Gelöschte Belege bleiben erhalten und können wiederhergestellt werden." : "Alle Einkäufe auf einen Blick – die neuesten stehen oben."}
{loading ? "…" : entries.length}
{search ? "Treffer" : entries.length === 1 ? "gespeicherter Bon" : "gespeicherte Bons"}
+ setTrash(false)}>Meine Bons setTrash(true)}>Papierkorb
setSearch(event.target.value)} placeholder="Händler, Produkt oder Kategorie suchen" />
{error && }
{loading ? Einkäufe werden geladen …
: entries.length === 0 ? (
- {search ? "Keine Treffer" : "Noch keine Bons"} {search ? "Versuche einen anderen Suchbegriff." : "Tippe unten rechts auf „Neuer Bon“, um deinen ersten Einkauf zu erfassen."}
+ {search ? "Keine Treffer" : trash ? "Papierkorb ist leer" : "Noch keine Bons"} {search ? "Versuche einen anderen Suchbegriff." : trash ? "Gelöschte Belege findest du hier." : "Tippe unten rechts auf „Neuer Bon“, um deinen ersten Einkauf zu erfassen."}
) : (
{groups.map((group) => (
@@ -692,22 +851,27 @@ function HistoryScreen({ onOpen, onNew }: { onOpen: (id: string) => void; onNew:
))}
)}
- Vollständiges Backup herunterladen
+ {Capacitor.isNativePlatform() ? setBackupRevision((value) => value + 1)} /> : Vollständiges Backup herunterladen }
+ Neuer Bon
);
}
-function DetailScreen({ receipt, onBack, onNew }: { receipt: StoredReceipt; onBack: () => void; onNew: () => void }) {
+function DetailScreen({ receipt, onBack, onNew, onEdit, onDelete, onRestore, saving }: { receipt: StoredReceipt; onBack: () => void; onNew: () => void; onEdit: (itemId?: string | null) => void; onDelete: () => void; onRestore: () => void; saving: boolean }) {
const { draft, calculations } = receipt;
+ const [confirmDelete, setConfirmDelete] = useState(false);
return (
- ← Zum Verlauf + Neuer Bon
+ ← Zum Verlauf + Neuer Bon
+
+ {receipt.deletedAt ? <>
Dieser Beleg liegt im Papierkorb.
Beleg wiederherstellen > : <>
onEdit()}>Beleg bearbeiten setConfirmDelete(true)}>Beleg löschen >}
+
+ {confirmDelete && !receipt.deletedAt && Beleg in den Papierkorb verschieben? Der Beleg mit allen Artikeln und dem Originalbild bleibt wiederherstellbar.
setConfirmDelete(false)}>Abbrechen { setConfirmDelete(false); onDelete(); }}>In Papierkorb verschieben
}
Gespeicherter Einkauf
{draft.merchantName ?? "Händler unbekannt"} {draft.purchasedDate ?? "Datum unbekannt"}{draft.purchasedTime ? ` · ${draft.purchasedTime}` : ""}
Gesamt {formatMoney(draft.totalMinor, draft.currency)}
-
+
{receipt.attachment &&
}
@@ -723,11 +887,11 @@ function DetailScreen({ receipt, onBack, onNew }: { receipt: StoredReceipt; onBa
Artikel {calculations.positionCount} Positionen
- {draft.items.filter((item) => !item.excluded).map((item, index) =>
{String(index + 1).padStart(2, "0")} {item.normalizedName || item.rawName || "Unbenannter Artikel"} {item.quantity ? `${item.quantity} ${item.quantityUnit ?? ""}` : "Menge unbekannt"}{item.category ? ` · ${item.category}` : ""}
{formatMoney(item.lineTotalMinor, draft.currency)} )}
+ {draft.items.filter((item) => !item.excluded).map((item, index) =>
{String(index + 1).padStart(2, "0")} {item.normalizedName || item.rawName || "Unbenannter Artikel"} {item.quantity ? `${item.quantity} ${item.quantityUnit ?? ""}` : "Menge unbekannt"}{item.category ? ` · ${item.category}` : ""} {!receipt.deletedAt && onEdit(item.id)} aria-label={`Artikel ${item.normalizedName || item.rawName || index + 1} bearbeiten`}>Bearbeiten }
{formatMoney(item.lineTotalMinor, draft.currency)} )}
Positionen {formatMoney(calculations.itemsTotalMinor, draft.currency)}
Steuern {formatMoney(draft.taxTotalMinor, draft.currency)}
Gesamt {formatMoney(draft.totalMinor, draft.currency)}
{draft.notes && Weitere Informationen {draft.notes}
}
-
+ {Capacitor.isNativePlatform() ? Vollständige Handy-Sicherung unter „Zum Verlauf“ → „Daten & Sicherung“.
: }
);
}
@@ -744,10 +908,27 @@ function Field({ label, value, onChange, placeholder, type = "text", wide = fals
return {label} onChange(event.target.value || null)} /> ;
}
+function CurrencyField({ draft, onConfirm }: { draft: ReceiptDraft; onConfirm: (currency: string) => void }) {
+ const pending = needsCurrencyReview(draft);
+ const known = isKnownCurrency(draft.currency);
+ const options = [...new Set(["CAD", "EUR", "USD", "GBP", ...(known ? [draft.currency] : [])])];
+ return
+
Währung{pending ? " · Bitte prüfen" : ""}
+
+ onConfirm(event.target.value)}>
+ Bitte Währung wählen
+ {options.map((currency) => {currency} )}
+
+ {pending && known && onConfirm(draft.currency)}>{draft.currency} bestätigen }
+
+ {pending &&
{known ? `Die Währung wurde nicht sicher erkannt. ${draft.currency} ist vorbelegt – bitte bestätigen oder ändern.` : "Die Währung fehlt oder wurde nicht erkannt. Bitte auswählen; dein JSON musst du dafür nicht ändern."}
}
+
;
+}
+
function MoneyField({ label, value, currency, onChange }: { label: string; value: number | null; currency: string; onChange: (value: number | null) => void }) {
const [text, setText] = useState(value === null ? "" : (value / 100).toFixed(2));
useEffect(() => setText(value === null ? "" : (value / 100).toFixed(2)), [value]);
- return {label} { setText(event.target.value); const parsed = parseMoney(event.target.value); if (parsed !== undefined) onChange(parsed); }} />{currency}
;
+ return {label} { setText(event.target.value); const parsed = parseMoney(event.target.value); if (parsed !== undefined) onChange(parsed); }} />{currency === UNKNOWN_CURRENCY ? "—" : currency}
;
}
function SummaryMoney({ label, value, currency, onChange, readOnly = false, emphasized = false }: { label: string; value: number | null; currency: string; onChange?: (value: number | null) => void; readOnly?: boolean; emphasized?: boolean }) {
@@ -757,12 +938,15 @@ function SummaryMoney({ label, value, currency, onChange, readOnly = false, emph
function MoneyInline({ value, currency, onChange }: { value: number | null; currency: string; onChange: (value: number | null) => void }) {
const [text, setText] = useState(value === null ? "" : (value / 100).toFixed(2));
useEffect(() => setText(value === null ? "" : (value / 100).toFixed(2)), [value]);
- return { setText(event.target.value); const parsed = parseMoney(event.target.value); if (parsed !== undefined) onChange(parsed); }} />{currency} ;
+ return { setText(event.target.value); const parsed = parseMoney(event.target.value); if (parsed !== undefined) onChange(parsed); }} />{currency === UNKNOWN_CURRENCY ? "—" : currency} ;
}
-function StatusPill({ calculations, uncertaintyCount, compact = false }: { calculations: ReturnType; uncertaintyCount: number; compact?: boolean }) {
- const tone = calculations.isBalanced ? "success" : calculations.missingPriceCount > 0 ? "incomplete" : "warning";
- return {calculations.isBalanced ?
:
}
{calculations.isBalanced ? "Beträge stimmen überein" : calculations.missingPriceCount > 0 ? "Unvollständige Daten" : "Prüfung erforderlich"} {!compact && {uncertaintyCount ? `${uncertaintyCount} unsichere Felder` : calculations.isBalanced ? "Bereit zum Speichern" : "Gedruckte und berechnete Summe unterscheiden sich"} } ;
+function StatusPill({ calculations, uncertaintyCount, currencyNeedsReview = false, compact = false }: { calculations: ReturnType; uncertaintyCount: number; currencyNeedsReview?: boolean; compact?: boolean }) {
+ const ready = calculations.isBalanced && !currencyNeedsReview;
+ const tone = ready ? "success" : calculations.missingPriceCount > 0 ? "incomplete" : "warning";
+ const title = calculations.isBalanced ? currencyNeedsReview ? "Währung prüfen" : "Beträge stimmen überein" : calculations.missingPriceCount > 0 ? "Unvollständige Daten" : "Prüfung erforderlich";
+ const hint = currencyNeedsReview ? "Währung auswählen oder bestätigen" : uncertaintyCount ? `${uncertaintyCount} unsichere Felder` : calculations.isBalanced ? "Bereit zum Speichern" : "Gedruckte und berechnete Summe unterscheiden sich";
+ return {ready ?
:
}
{title} {!compact && {hint} } ;
}
function DetailRow({ label, value }: { label: string; value: string | null }) {
diff --git a/src/BackupPanel.tsx b/src/BackupPanel.tsx
new file mode 100644
index 0000000..04d0f22
--- /dev/null
+++ b/src/BackupPanel.tsx
@@ -0,0 +1,47 @@
+import { useState } from "react";
+import { summarizeBackup, type NativeBackup } from "../shared/native-backup";
+import { exportPhoneBackup, importPhoneBackup, selectPhoneBackup } from "./native/backup";
+
+export default function BackupPanel({ onRestored }: { onRestored: () => void }) {
+ const [busy, setBusy] = useState(false);
+ const [pending, setPending] = useState(null);
+ const [notice, setNotice] = useState<{ error: boolean; text: string } | null>(null);
+ const summary = pending ? summarizeBackup(pending) : null;
+
+ async function run(operation: () => Promise) {
+ if (busy) return;
+ setBusy(true); setNotice(null);
+ try { await operation(); }
+ catch (error) { setNotice({ error: true, text: error instanceof Error ? error.message : "Die Sicherung konnte nicht verarbeitet werden." }); }
+ finally { setBusy(false); }
+ }
+
+ return
+ Daten & Sicherung
+ Alle gespeicherten Bons, Artikel, Bonbilder, Original-JSON und Änderungen sichern – einschließlich Entwürfen und Papierkorb. Noch nicht gespeicherte Eingaben sind nicht enthalten.
+ Die Datei ist nicht verschlüsselt und enthält persönliche Daten. Speichere sie außerhalb der App, zum Beispiel in „Downloads“. Nur wenn du selbst einen Cloud-Speicher auswählst, wird sie dort abgelegt.
+
+ void run(async () => {
+ if (await exportPhoneBackup()) setNotice({ error: false, text: "Sicherung gespeichert und geprüft. Sie bleibt bei einer Deinstallation erhalten." });
+ })}>Backup exportieren
+ void run(async () => { setPending(await selectPhoneBackup()); })}>Backup importieren
+
+ Handy-Sicherungsformat Version 1 · maximal 100 MB. Bestehende Webserver-Backups sind ein anderes Format.
+ {busy && Sicherung wird verarbeitet … Bitte die App geöffnet lassen.
}
+ {summary &&
+
Sicherung wiederherstellen?
+
{summary.receipts} Bons · {summary.items} Artikel · {summary.images} Bilder · {summary.trash} Bons im Papierkorb
+
Erstellt: {new Date(summary.exportedAt).toLocaleString("de-DE")}
+
Nur in eine leere Datenbank. Vorhandene Belege werden niemals überschrieben oder zusammengeführt.
+
+ void run(async () => {
+ const result = await importPhoneBackup(pending!);
+ setPending(null); onRestored();
+ setNotice({ error: false, text: result.preferenceWarning ? "Belege und Bilder wiederhergestellt. Einstellungen konnten nicht vollständig übernommen werden." : "Belege, Bilder und Einstellungen wiederhergestellt. Ein importierter Farbmodus wird beim nächsten App-Start angewendet." });
+ })}>Jetzt wiederherstellen
+ { setPending(null); setNotice(null); }}>Abbrechen
+
+
}
+ {notice && }
+ ;
+}
diff --git a/src/api.ts b/src/api.ts
index 6a1a95a..5a4ee82 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -1,4 +1,6 @@
-import type { AttachmentToken, ExtractionResponse, ReceiptDraft } from "../shared/receipt";
+import type { AttachmentToken, ExtractionResponse, ReceiptDraft, UpdateReceiptRequest, ReceiptMutationRequest } from "../shared/receipt.js";
+import { Capacitor } from "@capacitor/core";
+import * as nativeApi from "./native/api.js";
export interface AppConfig {
extractionMode: "manual" | "external";
@@ -22,6 +24,8 @@ export interface HistoryEntry {
export interface StoredReceipt {
id: string;
+ revision: number;
+ deletedAt: string | null;
status: "draft" | "confirmed";
validationState: "incomplete" | "discrepancy" | "balanced";
scannedAt: string;
@@ -48,16 +52,24 @@ async function json(input: RequestInfo, init?: RequestInit): Promise {
}
export function loadConfig(): Promise {
+ if (Capacitor.isNativePlatform()) return nativeApi.loadConfig();
return json("/api/config");
}
+export function prepareReceiptImage(file: File): Promise {
+ if (Capacitor.isNativePlatform()) return nativeApi.prepareReceiptImage(file);
+ return Promise.resolve(null);
+}
+
export async function extractReceipt(file: File): Promise {
+ if (Capacitor.isNativePlatform()) return nativeApi.extractReceipt(file);
const data = new FormData();
data.append("receipt", file);
return json("/api/extractions", { method: "POST", body: data });
}
export async function importChatGptReceipt(content: string, file: File | null): Promise {
+ if (Capacitor.isNativePlatform()) return nativeApi.importChatGptReceipt(content, file);
const data = new FormData();
data.append("content", content);
if (file) data.append("receipt", file);
@@ -71,6 +83,7 @@ export async function saveReceipt(input: {
attachment: AttachmentToken | null;
extractionId: string | null;
}): Promise<{ id: string; receipt: StoredReceipt }> {
+ if (Capacitor.isNativePlatform()) return nativeApi.saveReceipt(input);
return json("/api/receipts", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -78,10 +91,26 @@ export async function saveReceipt(input: {
});
}
-export function loadHistory(search = ""): Promise<{ receipts: HistoryEntry[] }> {
- return json(`/api/receipts?q=${encodeURIComponent(search)}`);
+export function loadHistory(search = "", trash = false): Promise<{ receipts: HistoryEntry[] }> {
+ if (Capacitor.isNativePlatform()) return nativeApi.loadHistory(search, trash);
+ return json(`/api/receipts?q=${encodeURIComponent(search)}&trash=${trash}`);
+}
+
+export function updateReceipt(id: string, input: UpdateReceiptRequest): Promise<{ id: string; receipt: StoredReceipt }> {
+ if (Capacitor.isNativePlatform()) return nativeApi.updateReceipt(id, input);
+ return json(`/api/receipts/${encodeURIComponent(id)}`, {
+ method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input)
+ });
+}
+
+export function changeReceiptDeleted(id: string, input: ReceiptMutationRequest, deleted: boolean): Promise<{ id: string; receipt: StoredReceipt }> {
+ if (Capacitor.isNativePlatform()) return nativeApi.changeReceiptDeleted(id, input, deleted);
+ return json(`/api/receipts/${encodeURIComponent(id)}${deleted ? "" : "/restore"}`, {
+ method: deleted ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input)
+ });
}
export function loadReceipt(id: string): Promise<{ receipt: StoredReceipt }> {
+ if (Capacitor.isNativePlatform()) return nativeApi.loadReceipt(id);
return json(`/api/receipts/${encodeURIComponent(id)}`);
}
diff --git a/src/currency-preference.ts b/src/currency-preference.ts
new file mode 100644
index 0000000..249c757
--- /dev/null
+++ b/src/currency-preference.ts
@@ -0,0 +1,26 @@
+import { isKnownCurrency, needsCurrencyReview } from "../shared/currency.js";
+import type { ReceiptDraft } from "../shared/receipt.js";
+
+const preferenceKey = "itemly-last-currency";
+
+export function readLastCurrency(): string | null {
+ try {
+ const value = localStorage.getItem(preferenceKey);
+ return isKnownCurrency(value) ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeLastCurrency(currency: string): void {
+ if (!isKnownCurrency(currency)) return;
+ try {
+ localStorage.setItem(preferenceKey, currency);
+ } catch {
+ // Preference storage must never turn a successful receipt save into a failure.
+ }
+}
+
+export function currencyToRemember(draft: ReceiptDraft): string | null {
+ return needsCurrencyReview(draft) ? null : draft.currency;
+}
diff --git a/src/main.tsx b/src/main.tsx
index f557eed..9472e9a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,9 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { Capacitor } from "@capacitor/core";
import App from "./App";
import "./styles.css";
-if ("serviceWorker" in navigator && import.meta.env.PROD) {
+if ("serviceWorker" in navigator && import.meta.env.PROD && !Capacitor.isNativePlatform()) {
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js");
});
diff --git a/src/native/api.ts b/src/native/api.ts
new file mode 100644
index 0000000..fde7bab
--- /dev/null
+++ b/src/native/api.ts
@@ -0,0 +1,487 @@
+import { Directory, Filesystem } from "@capacitor/filesystem";
+import { needsCurrencyReview } from "../../shared/currency.js";
+import { CapacitorSQLite, SQLiteConnection, type SQLiteDBConnection } from "@capacitor-community/sqlite";
+import {
+ attachmentTokenSchema,
+ calculateReceipt,
+ createEmptyDraft,
+ createUuid,
+ receiptDraftSchema,
+ saveReceiptSchema,
+ updateReceiptSchema,
+ receiptMutationSchema,
+ type AttachmentToken,
+ type ExtractionResponse,
+ type SaveReceiptRequest,
+ type UpdateReceiptRequest,
+ type ReceiptMutationRequest
+} from "../../shared/receipt.js";
+import { parsePastedReceiptJson } from "../../shared/receipt-import.js";
+import { groupHistoryEntries } from "../history.js";
+import type { AppConfig, HistoryEntry, StoredReceipt } from "../api.js";
+import initialMigration from "./migrations/001_initial.sql?raw";
+import crudMigration from "./migrations/002_receipt_crud.sql?raw";
+import { validateNativeBackup, type NativeBackup } from "../../shared/native-backup.js";
+
+type ReceiptRow = {
+ id: string;
+ revision: number;
+ deleted_at: string | null;
+ client_mutation_id: string;
+ status: "draft" | "confirmed";
+ scanned_at: string;
+ confirmed_at: string | null;
+ draft_json: string;
+ attachment_json: string | null;
+ image_sha256: string | null;
+ extraction_json: string | null;
+};
+
+const databaseName = "itemly";
+const imageFolder = "receipts";
+const maximumImageBytes = 15 * 1024 * 1024;
+const mimeExtensions: Record = {
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ "image/webp": "webp",
+ "image/heic": "heic",
+ "image/heif": "heif"
+};
+const preparedImages = new WeakMap();
+const pendingExtractions = new Map();
+let connectionPromise: Promise | null = null;
+
+async function database(): Promise {
+ connectionPromise ??= openDatabase().catch((error: unknown) => {
+ connectionPromise = null;
+ throw error;
+ });
+ return connectionPromise;
+}
+
+async function openDatabase(): Promise {
+ const sqlite = new SQLiteConnection(CapacitorSQLite);
+ const db = await sqlite.createConnection(databaseName, false, "no-encryption", 2, false);
+ await db.open();
+ const versionRows = await db.query("PRAGMA user_version;");
+ const version = Number((versionRows.values?.[0] as { user_version?: number } | undefined)?.user_version ?? 0);
+ if (version > 2) throw new Error("Diese Itemly-Datenbank wurde mit einer neueren App-Version erstellt.");
+ if (version < 1) await db.execute(initialMigration);
+ if (version < 2) await db.execute(crudMigration);
+ // Only remove content-addressed files with no committed receipt reference.
+ // This reclaims images selected before an abandoned or interrupted import.
+ await pruneUnreferencedImages(db).catch(() => undefined);
+ return db;
+}
+
+async function pruneUnreferencedImages(db: SQLiteDBConnection): Promise {
+ const result = await db.query("SELECT image_sha256 FROM receipts WHERE image_sha256 IS NOT NULL;");
+ const referenced = new Set((result.values ?? []).map((row) => (row as { image_sha256: string }).image_sha256));
+ const staleBefore = Date.now() - 24 * 60 * 60 * 1000;
+ let files;
+ try {
+ files = (await Filesystem.readdir({ path: imageFolder, directory: Directory.Data })).files;
+ } catch {
+ return;
+ }
+ for (const file of files) {
+ // Keep recently selected images across a WebView/HMR restart so an
+ // in-progress import cannot lose its private copy.
+ if (file.type !== "file" || !Number.isFinite(file.mtime) || file.mtime > staleBefore) continue;
+ if (!/^[a-f0-9]{64}\.(?:jpg|png|webp|heic|heif)$/.test(file.name)) continue;
+ if (referenced.has(file.name.slice(0, 64))) continue;
+ await Filesystem.deleteFile({ path: `${imageFolder}/${file.name}`, directory: Directory.Data }).catch(() => undefined);
+ }
+}
+
+async function rows(sql: string, values: (string | number | null)[] = []): Promise {
+ const result = await (await database()).query(sql, values);
+ return (result.values ?? []) as T[];
+}
+
+function imagePath(token: string): string {
+ // Never use a path supplied by pasted JSON or an unvalidated token.
+ return `${imageFolder}/${attachmentTokenSchema.shape.token.parse(token)}`;
+}
+
+async function hashBytes(bytes: ArrayBuffer): Promise {
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
+}
+
+function toBase64(bytes: ArrayBuffer): string {
+ const view = new Uint8Array(bytes);
+ const chunks: string[] = [];
+ for (let index = 0; index < view.length; index += 32_768) {
+ chunks.push(String.fromCharCode(...view.subarray(index, index + 32_768)));
+ }
+ return btoa(chunks.join(""));
+}
+
+async function verifyStoredImage(path: string, expectedSha256: string): Promise {
+ let data;
+ try {
+ ({ data } = await Filesystem.readFile({ path, directory: Directory.Data }));
+ } catch {
+ throw new Error("Das lokal gespeicherte Bonbild ist nicht verfügbar. Bitte wähle es erneut aus.");
+ }
+ if (typeof data !== "string") throw new Error("Das gespeicherte Bonbild konnte nicht gelesen werden.");
+ const binary = atob(data);
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
+ if (await hashBytes(bytes.buffer) !== expectedSha256) {
+ throw new Error("Das gespeicherte Bonbild ist beschädigt. Bitte wähle es erneut aus.");
+ }
+}
+
+export async function prepareReceiptImage(file: File): Promise {
+ const mimeType = file.type === "image/jpg" ? "image/jpeg" : file.type.toLowerCase();
+ const extension = mimeExtensions[mimeType];
+ if (!extension) throw new Error("Unterstützt werden JPEG, PNG, WebP, HEIC und HEIF.");
+ if (file.size < 1 || file.size > maximumImageBytes) throw new Error("Das Bonbild muss zwischen 1 Byte und 15 MB groß sein.");
+ let bytes: ArrayBuffer;
+ try {
+ bytes = await file.arrayBuffer();
+ } catch {
+ throw new Error("Das gewählte Bonbild ist nicht lesbar. Wähle es erneut oder importiere das JSON ohne Bild.");
+ }
+ if (bytes.byteLength !== file.size) throw new Error("Das Bonbild wurde nicht vollständig gelesen. Wähle es erneut aus.");
+ await database();
+ const sha256 = await hashBytes(bytes);
+ const token = `${sha256}.${extension}`;
+ const path = imagePath(token);
+ let alreadyStored = false;
+ try {
+ await Filesystem.stat({ path, directory: Directory.Data });
+ alreadyStored = true;
+ } catch {
+ // The content-addressed image has not been copied into private storage.
+ }
+ if (alreadyStored) {
+ await verifyStoredImage(path, sha256);
+ } else {
+ await Filesystem.writeFile({ path, directory: Directory.Data, data: toBase64(bytes), recursive: true });
+ await verifyStoredImage(path, sha256);
+ }
+ const attachment = attachmentTokenSchema.parse({
+ token,
+ sha256,
+ mimeType,
+ originalName: file.name.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 180) || "receipt-image",
+ imageUrl: URL.createObjectURL(new Blob([bytes], { type: mimeType }))
+ });
+ preparedImages.set(file, attachment);
+ return attachment;
+}
+
+async function imageFromFile(file: File): Promise {
+ return preparedImages.get(file) ?? prepareReceiptImage(file);
+}
+
+async function existingDuplicate(sha256: string | undefined): Promise {
+ if (!sha256) return null;
+ await writeQueue;
+ const match = await rows<{ id: string }>("SELECT id FROM receipts WHERE image_sha256 = ? AND deleted_at IS NULL LIMIT 1;", [sha256]);
+ return match[0]?.id ?? null;
+}
+
+function extraction(provider: string, rawResult: unknown): ExtractionResponse["extraction"] {
+ const id = createUuid();
+ pendingExtractions.set(id, { provider, rawResult, extractedAt: new Date().toISOString() });
+ return {
+ id,
+ provider,
+ model: null,
+ status: provider === "manual" ? "manual" : "completed",
+ disclosure: provider === "manual"
+ ? "Das Bild bleibt auf diesem Gerät. Bitte erfasse die Angaben manuell."
+ : "Das eingefügte JSON wurde lokal geprüft. Itemly hat keine KI-API aufgerufen.",
+ duplicateReceiptId: null
+ };
+}
+
+export async function loadConfig(): Promise {
+ await database();
+ return {
+ extractionMode: "manual",
+ provider: "manual",
+ model: null,
+ disclosure: "Das Bild bleibt auf diesem Gerät. Es wird kein KI-Dienst aufgerufen; erfasse die Werte selbst oder importiere ChatGPT-JSON."
+ };
+}
+
+export async function extractReceipt(file: File): Promise {
+ const attachment = await imageFromFile(file);
+ return {
+ draft: createEmptyDraft(),
+ attachment,
+ extraction: {
+ ...extraction("manual", null),
+ duplicateReceiptId: await existingDuplicate(attachment.sha256)
+ }
+ };
+}
+
+export async function importChatGptReceipt(content: string, file: File | null): Promise {
+ const { draft, rawResult } = parsePastedReceiptJson(content);
+ const attachment = file ? await imageFromFile(file) : null;
+ return {
+ draft,
+ attachment,
+ extraction: {
+ ...extraction("chatgpt-paste", rawResult),
+ duplicateReceiptId: await existingDuplicate(attachment?.sha256)
+ }
+ };
+}
+
+async function ensureImageSaved(attachment: AttachmentToken): Promise {
+ if (attachment.token !== `${attachment.sha256}.${mimeExtensions[attachment.mimeType]}`) {
+ throw new Error("Das Bonbild ist ungültig. Wähle es erneut aus.");
+ }
+ await verifyStoredImage(imagePath(attachment.token), attachment.sha256);
+}
+
+async function storedAttachment(value: string | null): Promise {
+ if (!value) return null;
+ const attachment = attachmentTokenSchema.parse(JSON.parse(value));
+ const { data } = await Filesystem.readFile({ path: imagePath(attachment.token), directory: Directory.Data });
+ if (typeof data !== "string") throw new Error("Das gespeicherte Bonbild konnte nicht gelesen werden.");
+ return { ...attachment, imageUrl: `data:${attachment.mimeType};base64,${data}` };
+}
+
+async function mapReceipt(row: ReceiptRow): Promise {
+ const draft = receiptDraftSchema.parse(JSON.parse(row.draft_json));
+ return {
+ id: row.id,
+ revision: row.revision,
+ deletedAt: row.deleted_at,
+ status: row.status,
+ validationState: validationState(draft),
+ scannedAt: row.scanned_at,
+ confirmedAt: row.confirmed_at,
+ draft,
+ attachment: await storedAttachment(row.attachment_json),
+ calculations: calculateReceipt(draft)
+ };
+}
+
+function validationState(draft: ReturnType): StoredReceipt["validationState"] {
+ const calculation = calculateReceipt(draft);
+ if (draft.totalMinor === null || calculation.missingPriceCount > 0 || needsCurrencyReview(draft)) return "incomplete";
+ return calculation.isBalanced ? "balanced" : "discrepancy";
+}
+
+export async function saveReceipt(input: SaveReceiptRequest): Promise<{ id: string; receipt: StoredReceipt }> {
+ return withWriteLock(() => saveNewReceipt(input));
+}
+
+// A connection cannot host overlapping transactions. Queue public reads too,
+// so a concurrent history view never observes a partially restored transaction.
+let writeQueue: Promise = Promise.resolve();
+function withWriteLock(operation: () => Promise): Promise {
+ const result = writeQueue.then(operation);
+ writeQueue = result.catch(() => undefined);
+ return result;
+}
+
+async function saveNewReceipt(input: SaveReceiptRequest): Promise<{ id: string; receipt: StoredReceipt }> {
+ const request = saveReceiptSchema.parse(input);
+ const db = await database();
+ const prior = await rows("SELECT * FROM receipts WHERE client_mutation_id = ? LIMIT 1;", [request.clientMutationId]);
+ if (prior[0]) return { id: prior[0].id, receipt: await mapReceipt(prior[0]) };
+
+ if (request.status === "confirmed" && needsCurrencyReview(request.draft)) {
+ throw new Error("Bitte wähle oder bestätige die Währung. Der Bon kann bereits als Entwurf gespeichert werden.");
+ }
+
+ const calculations = calculateReceipt(request.draft);
+ if (request.status === "confirmed" && !calculations.isBalanced) {
+ throw new Error("Ein nicht ausgeglichener Bon kann nur als Entwurf gespeichert werden.");
+ }
+ const attachment = request.attachment ? attachmentTokenSchema.parse(request.attachment) : null;
+ if (attachment) await ensureImageSaved(attachment);
+ const id = createUuid();
+ const scannedAt = new Date().toISOString();
+ // One row contains the validated draft and its metadata, so SQLite commits
+ // the receipt atomically; the unique mutation ID makes retries idempotent.
+ await db.run(`INSERT OR IGNORE INTO receipts
+ (id, client_mutation_id, status, scanned_at, confirmed_at, draft_json, attachment_json, image_sha256, extraction_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, [
+ id,
+ request.clientMutationId,
+ request.status,
+ scannedAt,
+ request.status === "confirmed" ? scannedAt : null,
+ JSON.stringify(request.draft),
+ attachment ? JSON.stringify({ ...attachment, imageUrl: "" }) : null,
+ attachment?.sha256 ?? null,
+ request.extractionId ? JSON.stringify(pendingExtractions.get(request.extractionId) ?? null) : null
+ ]);
+ const saved = await rows("SELECT * FROM receipts WHERE client_mutation_id = ? LIMIT 1;", [request.clientMutationId]);
+ if (!saved[0]) throw new Error("Der Bon konnte nicht gespeichert werden.");
+ if (request.extractionId) pendingExtractions.delete(request.extractionId);
+ return { id: saved[0].id, receipt: await mapReceipt(saved[0]) };
+}
+
+export async function loadHistory(search = "", trash = false): Promise<{ receipts: HistoryEntry[] }> {
+ return withWriteLock(() => loadHistoryUnlocked(search, trash));
+}
+
+async function loadHistoryUnlocked(search: string, trash: boolean): Promise<{ receipts: HistoryEntry[] }> {
+ const all = await rows("SELECT * FROM receipts ORDER BY scanned_at DESC;");
+ const term = search.trim().toLocaleLowerCase();
+ const entries = all.flatMap((row) => {
+ if (Boolean(row.deleted_at) !== trash) return [];
+ const draft = receiptDraftSchema.parse(JSON.parse(row.draft_json));
+ if (term && ![draft.merchantName, ...draft.items.flatMap((item) => [item.normalizedName, item.category])]
+ .some((value) => value?.toLocaleLowerCase().includes(term))) return [];
+ return [{
+ id: row.id,
+ merchantName: draft.merchantName,
+ purchasedDate: draft.purchasedDate,
+ purchasedTime: draft.purchasedTime,
+ scannedAt: row.scanned_at,
+ totalMinor: draft.totalMinor,
+ currency: draft.currency,
+ positionCount: calculateReceipt(draft).positionCount,
+ status: row.status,
+ validationState: validationState(draft)
+ } satisfies HistoryEntry];
+ });
+ return { receipts: groupHistoryEntries(entries).flatMap((group) => group.entries) };
+}
+
+export async function loadReceipt(id: string): Promise<{ receipt: StoredReceipt }> {
+ return withWriteLock(() => loadReceiptUnlocked(id));
+}
+
+async function loadReceiptUnlocked(id: string): Promise<{ receipt: StoredReceipt }> {
+ const match = await rows("SELECT * FROM receipts WHERE id = ? LIMIT 1;", [id]);
+ if (!match[0]) throw new Error("Einkauf nicht gefunden.");
+ return { receipt: await mapReceipt(match[0]) };
+}
+
+async function mutateReceipt(id: string, input: ReceiptMutationRequest, action: "update" | "delete" | "restore", update?: UpdateReceiptRequest): Promise<{ id: string; receipt: StoredReceipt }> {
+ const request = receiptMutationSchema.parse(input);
+ const db = await database();
+ await db.beginTransaction();
+ try {
+ const mutations = await rows<{ receipt_id: string; action: string }>("SELECT receipt_id, action FROM receipt_mutations WHERE id = ?;", [request.clientMutationId]);
+ const mutation = mutations[0];
+ if (mutation && (mutation.receipt_id !== id || mutation.action !== action)) throw new Error("Diese Änderung gehört zu einem anderen Vorgang.");
+ const found = await rows("SELECT * FROM receipts WHERE id = ? LIMIT 1;", [id]);
+ const previous = found[0];
+ if (!previous) throw new Error("Einkauf nicht gefunden.");
+ if (!mutation) {
+ if (previous.revision !== request.expectedRevision || (action === "restore" ? !previous.deleted_at : previous.deleted_at)) {
+ throw new Error("Der Beleg wurde geändert oder gelöscht. Bitte öffne ihn erneut; deine Änderungen bleiben erhalten.");
+ }
+ const timestamp = new Date().toISOString();
+ let oldValue: string | null;
+ let newValue: string | null;
+ if (update) {
+ if (update.status === "confirmed" && needsCurrencyReview(update.draft)) throw new Error("Bitte wähle oder bestätige die Währung. Der Bon kann bereits als Entwurf gespeichert werden.");
+ if (update.status === "confirmed" && !calculateReceipt(update.draft).isBalanced) throw new Error("Ein nicht ausgeglichener Bon kann nur als Entwurf gespeichert werden.");
+ oldValue = previous.draft_json;
+ newValue = JSON.stringify(update.draft);
+ await db.run(`UPDATE receipts SET draft_json = ?, status = ?, confirmed_at = ?, revision = revision + 1 WHERE id = ?;`,
+ [newValue, update.status, update.status === "confirmed" ? previous.confirmed_at ?? timestamp : null, id], false);
+ } else {
+ oldValue = JSON.stringify(previous.deleted_at);
+ newValue = JSON.stringify(action === "delete" ? timestamp : null);
+ await db.run("UPDATE receipts SET deleted_at = ?, revision = revision + 1 WHERE id = ?;", [action === "delete" ? timestamp : null, id], false);
+ }
+ await db.run("INSERT INTO receipt_mutations (id, receipt_id, action, created_at) VALUES (?, ?, ?, ?);", [request.clientMutationId, id, action, timestamp], false);
+ await db.run("INSERT INTO receipt_audit_events (id, receipt_id, action, old_value, new_value, changed_at) VALUES (?, ?, ?, ?, ?, ?);", [createUuid(), id, action, oldValue, newValue, timestamp], false);
+ }
+ await db.commitTransaction();
+ } catch (error) {
+ await db.rollbackTransaction();
+ throw error;
+ }
+ return { id, receipt: (await loadReceiptUnlocked(id)).receipt };
+}
+
+export async function updateReceipt(id: string, input: UpdateReceiptRequest): Promise<{ id: string; receipt: StoredReceipt }> {
+ const request = updateReceiptSchema.parse(input);
+ return withWriteLock(() => mutateReceipt(id, request, "update", request));
+}
+
+export async function changeReceiptDeleted(id: string, input: ReceiptMutationRequest, deleted: boolean): Promise<{ id: string; receipt: StoredReceipt }> {
+ return withWriteLock(() => mutateReceipt(id, input, deleted ? "delete" : "restore"));
+}
+
+export async function createNativeBackup(preferences: NativeBackup["preferences"]): Promise {
+ return withWriteLock(async () => {
+ const receipts = await rows("SELECT * FROM receipts ORDER BY id;");
+ const mutations = await rows("SELECT * FROM receipt_mutations ORDER BY id;");
+ const auditEvents = await rows("SELECT * FROM receipt_audit_events ORDER BY id;");
+ const images = new Map();
+ let totalBase64 = 0;
+ for (const row of receipts) {
+ if (!row.attachment_json) continue;
+ const attachment = attachmentTokenSchema.parse(JSON.parse(row.attachment_json));
+ if (images.has(attachment.token)) continue;
+ let data: string | Blob;
+ try {
+ ({ data } = await Filesystem.readFile({ path: imagePath(attachment.token), directory: Directory.Data }));
+ } catch {
+ throw new Error("Ein gespeichertes Bonbild fehlt oder ist nicht lesbar. Die vollständige Sicherung wurde nicht erstellt.");
+ }
+ if (typeof data !== "string") throw new Error("Ein Bonbild konnte nicht gesichert werden.");
+ totalBase64 += data.length;
+ if (totalBase64 > 100 * 1024 * 1024) throw new Error("Die Sicherung ist zu groß. Unterstützt werden derzeit bis zu 100 MB.");
+ images.set(attachment.token, { token: attachment.token, sha256: attachment.sha256, mimeType: attachment.mimeType, dataBase64: data });
+ }
+ return validateNativeBackup({ format: "itemly-android-backup", version: 1, exportedAt: new Date().toISOString(), receipts, mutations, auditEvents, images: [...images.values()], preferences });
+ });
+}
+
+export async function restoreNativeBackup(raw: unknown): Promise {
+ const backup = await validateNativeBackup(raw);
+ return withWriteLock(async () => {
+ const db = await database();
+ await db.beginTransaction();
+ const createdImages: string[] = [];
+ try {
+ // Include trash and ledgers: never overwrite an existing installation.
+ for (const table of ["receipts", "receipt_mutations", "receipt_audit_events"]) {
+ const count = await rows<{ count: number }>(`SELECT COUNT(*) AS count FROM ${table};`);
+ if (count[0]?.count !== 0) throw new Error("Wiederherstellen ist nur in einer leeren Datenbank möglich – auch der Papierkorb muss leer sein. Vorhandene Daten werden nicht überschrieben.");
+ }
+ for (const image of backup.images) {
+ const path = imagePath(image.token);
+ let exists = false;
+ try { await Filesystem.stat({ path, directory: Directory.Data }); exists = true; } catch { /* New image. */ }
+ if (exists) {
+ // The DB is empty, so a damaged file is an orphan from an interrupted
+ // capture/restore, not an existing receipt to overwrite.
+ try { await verifyStoredImage(path, image.sha256); } catch { exists = false; }
+ }
+ if (!exists) {
+ createdImages.push(path);
+ await Filesystem.writeFile({ path, directory: Directory.Data, data: image.dataBase64, recursive: true });
+ }
+ await verifyStoredImage(path, image.sha256);
+ }
+ for (const row of backup.receipts) {
+ await db.run(`INSERT INTO receipts (id, revision, deleted_at, client_mutation_id, status, scanned_at, confirmed_at, draft_json, attachment_json, image_sha256, extraction_json)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, [row.id, row.revision, row.deleted_at, row.client_mutation_id, row.status, row.scanned_at, row.confirmed_at, row.draft_json, row.attachment_json, row.image_sha256, row.extraction_json], false);
+ }
+ for (const row of backup.mutations) {
+ await db.run("INSERT INTO receipt_mutations (id, receipt_id, action, created_at) VALUES (?, ?, ?, ?);", [row.id, row.receipt_id, row.action, row.created_at], false);
+ }
+ for (const row of backup.auditEvents) {
+ await db.run("INSERT INTO receipt_audit_events (id, receipt_id, action, old_value, new_value, changed_at) VALUES (?, ?, ?, ?, ?, ?);", [row.id, row.receipt_id, row.action, row.old_value, row.new_value, row.changed_at], false);
+ }
+ await db.commitTransaction();
+ } catch (error) {
+ await db.rollbackTransaction();
+ // A process kill can leave orphan images, but never partial DB records.
+ // Startup pruning reclaims them; ordinary failures clean them immediately.
+ for (const path of createdImages) await Filesystem.deleteFile({ path, directory: Directory.Data }).catch(() => undefined);
+ throw error;
+ }
+ return backup.preferences;
+ });
+}
diff --git a/src/native/backup.ts b/src/native/backup.ts
new file mode 100644
index 0000000..f6085b5
--- /dev/null
+++ b/src/native/backup.ts
@@ -0,0 +1,54 @@
+import { registerPlugin } from "@capacitor/core";
+import { Directory, Encoding, Filesystem } from "@capacitor/filesystem";
+import { createUuid } from "../../shared/receipt.js";
+import { maximumBackupBytes, parseNativeBackup, type NativeBackup } from "../../shared/native-backup.js";
+import { createNativeBackup, restoreNativeBackup } from "./api.js";
+import { readLastCurrency } from "../currency-preference.js";
+
+const documents = registerPlugin<{
+ save(options: { cacheName: string }): Promise<{ cancelled: boolean }>;
+ open(): Promise<{ cancelled: boolean; cacheName?: string }>;
+}>("BackupDocuments");
+
+function backupPreferences(): NativeBackup["preferences"] {
+ return { lastCurrency: readLastCurrency(), theme: document.documentElement.dataset.theme === "dark" ? "dark" : "light" };
+}
+
+export async function exportPhoneBackup(): Promise {
+ const backup = await createNativeBackup(backupPreferences());
+ const content = JSON.stringify(backup);
+ if (new TextEncoder().encode(content).byteLength > maximumBackupBytes) throw new Error("Die Sicherung ist zu groß. Unterstützt werden derzeit bis zu 100 MB.");
+ const path = `itemly-backup-${createUuid()}.json`;
+ try {
+ await Filesystem.writeFile({ path, directory: Directory.Cache, data: content, encoding: Encoding.UTF8 });
+ return !(await documents.save({ cacheName: path })).cancelled;
+ } finally {
+ await Filesystem.deleteFile({ path, directory: Directory.Cache }).catch(() => undefined);
+ }
+}
+
+export async function selectPhoneBackup(): Promise {
+ const selected = await documents.open();
+ if (selected.cancelled) return null;
+ const path = selected.cacheName;
+ if (!path || !/^itemly-backup-[a-f0-9-]{36}\.json$/.test(path)) throw new Error("Die gewählte Sicherung ist nicht verfügbar.");
+ try {
+ const { data } = await Filesystem.readFile({ path, directory: Directory.Cache, encoding: Encoding.UTF8 });
+ if (typeof data !== "string") throw new Error("Die Sicherung konnte nicht gelesen werden.");
+ return await parseNativeBackup(data);
+ } finally {
+ await Filesystem.deleteFile({ path, directory: Directory.Cache }).catch(() => undefined);
+ }
+}
+
+export async function importPhoneBackup(backup: NativeBackup): Promise<{ preferenceWarning: boolean }> {
+ const preferences = await restoreNativeBackup(backup);
+ let preferenceWarning = false;
+ try {
+ if (preferences.lastCurrency) localStorage.setItem("itemly-last-currency", preferences.lastCurrency);
+ else localStorage.removeItem("itemly-last-currency");
+ if (preferences.theme) localStorage.setItem("itemly-theme", preferences.theme);
+ else localStorage.removeItem("itemly-theme");
+ } catch { preferenceWarning = true; }
+ return { preferenceWarning };
+}
diff --git a/src/native/image-picker.ts b/src/native/image-picker.ts
new file mode 100644
index 0000000..4391348
--- /dev/null
+++ b/src/native/image-picker.ts
@@ -0,0 +1,63 @@
+import { Camera, MediaTypeSelection, type MediaResult } from "@capacitor/camera";
+import { Filesystem } from "@capacitor/filesystem";
+
+const maximumImageBytes = 15 * 1024 * 1024;
+const mimeTypes: Record = {
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ webp: "image/webp",
+ heic: "image/heic",
+ heif: "image/heif"
+};
+
+export async function mediaResultToFile(result: MediaResult): Promise {
+ if (!result.uri) throw new Error("Das ausgewählte Bonbild hat keinen lesbaren Dateipfad.");
+ if (result.metadata?.size && result.metadata.size > maximumImageBytes) {
+ throw new Error("Das Bonbild darf höchstens 15 MB groß sein.");
+ }
+ const format = result.metadata?.format?.toLowerCase() || result.uri.match(/\.(jpe?g|png|webp|heic|heif)(?:[?#]|$)/i)?.[1]?.toLowerCase() || "jpg";
+ const mimeType = mimeTypes[format];
+ if (!mimeType) throw new Error("Unterstützt werden JPEG, PNG, WebP, HEIC und HEIF.");
+
+ let data: string | Blob;
+ try {
+ ({ data } = await Filesystem.readFile({ path: result.uri }));
+ } catch {
+ throw new Error("Das ausgewählte Bonbild konnte nicht gelesen werden. Bitte wähle es erneut aus.");
+ }
+ let bytes: Uint8Array;
+ if (typeof data === "string") {
+ const byteCount = Math.floor(data.length * 3 / 4) - (data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
+ if (byteCount < 1 || byteCount > maximumImageBytes) throw new Error("Das Bonbild muss zwischen 1 Byte und 15 MB groß sein.");
+ try {
+ bytes = Uint8Array.from(atob(data), (character) => character.charCodeAt(0));
+ } catch {
+ throw new Error("Das ausgewählte Bonbild enthält ungültige Bilddaten.");
+ }
+ } else {
+ if (data.size < 1 || data.size > maximumImageBytes) throw new Error("Das Bonbild muss zwischen 1 Byte und 15 MB groß sein.");
+ bytes = new Uint8Array(await data.arrayBuffer());
+ }
+ return new File([new Uint8Array(bytes).buffer], `receipt-${Date.now()}.${format === "jpeg" ? "jpg" : format}`, { type: mimeType });
+}
+
+export async function selectNativeImage(source: "camera" | "gallery"): Promise {
+ let result: MediaResult | undefined;
+ try {
+ if (source === "camera") {
+ result = await Camera.takePhoto({ quality: 100, includeMetadata: true, saveToGallery: false });
+ } else {
+ result = (await Camera.chooseFromGallery({
+ mediaType: MediaTypeSelection.Photo,
+ allowMultipleSelection: false,
+ includeMetadata: true
+ })).results[0];
+ }
+ } catch (error) {
+ const code = (error as { code?: string })?.code;
+ if (code === "OS-PLUG-CAMR-0006" || code === "OS-PLUG-CAMR-0020") return null;
+ throw new Error(`Die Bildauswahl ist fehlgeschlagen${code ? ` (${code})` : ""}. Bitte versuche es erneut.`);
+ }
+ return result ? mediaResultToFile(result) : null;
+}
diff --git a/src/native/migrations/001_initial.sql b/src/native/migrations/001_initial.sql
new file mode 100644
index 0000000..dcf0f0f
--- /dev/null
+++ b/src/native/migrations/001_initial.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS receipts (
+ id TEXT PRIMARY KEY,
+ client_mutation_id TEXT NOT NULL UNIQUE,
+ status TEXT NOT NULL CHECK (status IN ('draft', 'confirmed')),
+ scanned_at TEXT NOT NULL,
+ confirmed_at TEXT,
+ draft_json TEXT NOT NULL,
+ attachment_json TEXT,
+ image_sha256 TEXT,
+ extraction_json TEXT
+);
+CREATE INDEX IF NOT EXISTS receipts_scanned_at_idx ON receipts (scanned_at DESC);
+CREATE INDEX IF NOT EXISTS receipts_image_sha256_idx ON receipts (image_sha256);
+PRAGMA user_version = 1;
diff --git a/src/native/migrations/002_receipt_crud.sql b/src/native/migrations/002_receipt_crud.sql
new file mode 100644
index 0000000..3a7e482
--- /dev/null
+++ b/src/native/migrations/002_receipt_crud.sql
@@ -0,0 +1,17 @@
+ALTER TABLE receipts ADD COLUMN revision INTEGER NOT NULL DEFAULT 1;
+ALTER TABLE receipts ADD COLUMN deleted_at TEXT;
+CREATE TABLE receipt_mutations (
+ id TEXT PRIMARY KEY,
+ receipt_id TEXT NOT NULL REFERENCES receipts(id),
+ action TEXT NOT NULL CHECK(action IN ('update', 'delete', 'restore')),
+ created_at TEXT NOT NULL
+);
+CREATE TABLE receipt_audit_events (
+ id TEXT PRIMARY KEY,
+ receipt_id TEXT NOT NULL REFERENCES receipts(id),
+ action TEXT NOT NULL,
+ old_value TEXT,
+ new_value TEXT,
+ changed_at TEXT NOT NULL
+);
+PRAGMA user_version = 2;
diff --git a/src/styles.css b/src/styles.css
index 27b24dc..131e46d 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -165,6 +165,11 @@ h2, h3, p { margin-top: 0; }
.field > span, .notes-card label > span { display: block; color: #56635d; font-size: 11px; font-weight: 760; margin: 0 0 7px 2px; }
.field input, .field select, .notes-card textarea { width: 100%; border: 1px solid #d5d9d2; border-radius: 12px; background: #fbfbf7; color: var(--ink); min-height: 46px; padding: 0 13px; outline: none; transition: border .15s, box-shadow .15s, background .15s; }
.field select { cursor: pointer; }
+.currency-field > label { display: block; margin: 0 0 7px 2px; font-size: 12px; font-weight: 760; }
+.currency-controls { display: flex; flex-wrap: wrap; gap: 10px; }
+.currency-controls select { flex: 1; min-width: 150px; }
+.currency-field.needs-review select { border-color: var(--warning); }
+.currency-field p { margin: 9px 0 0; color: var(--warning); font-size: 12px; line-height: 1.5; }
.field input:focus, .field select:focus, .notes-card textarea:focus { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,77,.11); background: white; }
.field input::placeholder, textarea::placeholder { color: #9aa29e; font-style: italic; }
.items-section { margin: 34px 0; }
@@ -294,13 +299,22 @@ h2, h3, p { margin-top: 0; }
.detail-page { max-width: 920px; }
.detail-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 30px; }
+.detail-actions, .history-tabs { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 24px; }
+.button.danger { color: var(--error); border: 1px solid var(--error); background: transparent; }
+.delete-confirmation, .edit-notice { padding: 18px; margin-bottom: 24px; border: 1px solid var(--line); border-radius: 18px; background: var(--paper); overflow-wrap: anywhere; }
+.delete-confirmation h2 { margin-top: 0; font-size: 18px; }
+.delete-confirmation p, .edit-notice p { color: var(--muted); line-height: 1.6; }
+.delete-confirmation > div { display: flex; gap: 12px; flex-wrap: wrap; }
+.item-edit-link { display: block; margin-top: 8px; font-size: 12px; }
.text-button.back { text-decoration: none; padding-left: 0; }
+.detail-hero > div:first-child { min-width: 0; flex: 1; }
+.detail-hero h1 { overflow-wrap: anywhere; }
.detail-hero p { margin: 10px 0 0; color: var(--muted); }
-.detail-amount { text-align: right; }
+.detail-amount { min-width: 0; flex: 0 0 auto; text-align: right; }
.detail-amount span, .detail-amount strong { display: block; }
.detail-amount span { color: var(--muted); font-size: 10px; text-transform: uppercase; font-weight: 850; letter-spacing: .08em; }
.detail-amount strong { font-size: clamp(24px, 5vw, 38px); margin-top: 5px; }
-.detail-grid { display: grid; grid-template-columns: minmax(240px, .7fr) 1fr; gap: 20px; margin: 5px 0 30px; }
+.detail-grid { display: grid; grid-template-columns: minmax(240px, .7fr) minmax(0, 1fr); gap: 20px; margin: 5px 0 30px; }
.detail-image { min-height: 330px; max-height: 460px; background: #26312c; border-radius: 24px; display: grid; place-items: center; padding: 18px; overflow: auto; }
.detail-image img { max-width: 100%; max-height: 420px; object-fit: contain; }
.detail-facts { padding: 24px; }
@@ -308,7 +322,7 @@ h2, h3, p { margin-top: 0; }
.detail-facts dl { margin: 0; }
.detail-facts dl > div { display: grid; grid-template-columns: 115px 1fr; gap: 10px; padding: 11px 0; border-bottom: 1px solid #e7e8e3; font-size: 12px; }
.detail-facts dt { color: var(--muted); }
-.detail-facts dd { margin: 0; font-weight: 700; }
+.detail-facts dd { margin: 0; font-weight: 700; overflow-wrap: anywhere; }
.detail-facts dd.unknown { color: #999f9b; font-style: italic; font-weight: 500; }
.saved-items { border: 1px solid var(--line); border-radius: 20px; background: var(--paper); overflow: hidden; }
.saved-item { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 15px 18px; border-bottom: 1px solid #e5e7e1; }
@@ -508,8 +522,10 @@ html[data-theme="dark"] * { scrollbar-color: #3e5e52 #0c1419; }
.history-total { flex-direction: column; align-items: flex-end; gap: 2px; font-size: 12px; }
.history-total svg { display: none; }
.receipt-fab { right: 16px; bottom: max(14px, env(safe-area-inset-bottom)); min-height: 56px; padding: 0 19px 0 15px; border-radius: 18px; }
- .detail-hero { align-items: flex-start; }
- .detail-grid { grid-template-columns: 1fr; }
+ .detail-hero { flex-direction: column; align-items: stretch; gap: 16px; }
+ .detail-hero h1 { font-size: clamp(32px, 9vw, 42px); line-height: 1.05; }
+ .detail-amount { text-align: left; }
+ .detail-grid { grid-template-columns: minmax(0, 1fr); }
.detail-image { min-height: 280px; max-height: 390px; }
.detail-facts dl > div { grid-template-columns: 100px 1fr; }
.export-actions { flex-direction: column; }
@@ -531,3 +547,11 @@ html[data-theme="dark"] * { scrollbar-color: #3e5e52 #0c1419; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
+
+.backup-panel { margin: 28px 0 90px; min-width: 0; overflow-wrap: anywhere; }
+.backup-panel > summary { cursor: pointer; color: var(--green); font-weight: 700; font-size: 18px; min-height: 44px; }
+.backup-panel p { line-height: 1.6; }
+.backup-buttons { display: flex; flex-wrap: wrap; gap: 12px; margin: 18px 0; }
+.backup-buttons .button { flex: 1 1 180px; min-width: 0; white-space: normal; }
+.backup-preview { border-top: 1px solid var(--line); padding-top: 16px; margin-top: 20px; }
+.backup-privacy { padding: 14px; border: 1px solid var(--line); border-radius: 14px; }
diff --git a/tests/android-signing.test.ts b/tests/android-signing.test.ts
new file mode 100644
index 0000000..403cfb2
--- /dev/null
+++ b/tests/android-signing.test.ts
@@ -0,0 +1,33 @@
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
+
+describe("Android signing workflow boundaries", () => {
+ it("keeps durable credentials out of automatic pull-request builds", () => {
+ const pr = read(".github/workflows/android-apk.yml");
+ expect(pr).not.toContain("secrets.");
+ expect(pr).not.toContain("upload-artifact");
+ expect(pr).toContain("ORG_GRADLE_PROJECT_itemlyDurableSigning: 'true'");
+ const signed = read(".github/workflows/android-signed-apk.yml");
+ expect(signed).toContain("workflow_dispatch:");
+ expect(signed).not.toMatch(/^\s+pull_request(?:_target)?:/m);
+ expect(signed).not.toMatch(/^\s+push:/m);
+ expect(signed).toContain("persist-credentials: false");
+ expect(signed).toContain("ITEMLY_ANDROID_KEYSTORE_BASE64");
+ expect(signed).toContain("ORG_GRADLE_PROJECT_itemlyDurableSigning: 'true'");
+ expect(signed).toContain("if: always()");
+ });
+ it("refuses fallback signing and preserves the standalone preview app ID", () => {
+ const gradle = read("android/app/build.gradle");
+ expect(gradle).toContain("No temporary-key fallback is allowed.");
+ expect(gradle).toContain("applicationIdSuffix '.preview'");
+ expect(gradle).toContain("if (durableSigning) signingConfig signingConfigs.durablePreview");
+ expect(gradle).toContain("itemlyVersionCode");
+ expect(gradle).toContain("def previewVersionCode = (project.findProperty('itemlyVersionCode') ?: '1').toString().toInteger()");
+ expect(gradle).toContain("versionCode previewVersionCode");
+ expect(gradle).toContain("versionName previewVersionName");
+ expect(read(".gitignore")).toContain(".local/");
+ expect(read("scripts/setup-android-signing.ps1")).toContain("Refusing to overwrite");
+ });
+});
diff --git a/tests/currency.test.ts b/tests/currency.test.ts
new file mode 100644
index 0000000..28e9fe1
--- /dev/null
+++ b/tests/currency.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { applyCurrencyPreference, confirmCurrency, needsCurrencyReview, UNKNOWN_CURRENCY } from "../shared/currency.js";
+import { createEmptyDraft, formatMoney, receiptDraftSchema } from "../shared/receipt.js";
+import { currencyToRemember, readLastCurrency, writeLastCurrency } from "../src/currency-preference.js";
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("currency review and local preference", () => {
+ it("leaves the first receipt open instead of inventing a currency", () => {
+ const draft = applyCurrencyPreference(createEmptyDraft(), null);
+ expect(draft.currency).toBe(UNKNOWN_CURRENCY);
+ expect(needsCurrencyReview(draft)).toBe(true);
+ expect(currencyToRemember(draft)).toBeNull();
+ expect(formatMoney(525, draft.currency)).toBe("5,25");
+ });
+
+ it("suggests the previous currency without changing amounts or marking it reviewed", () => {
+ const draft = createEmptyDraft();
+ draft.totalMinor = 525;
+ const suggested = applyCurrencyPreference(draft, "CAD");
+ expect(suggested).toMatchObject({ currency: "CAD", totalMinor: 525, fieldSources: { currency: "uncertain" } });
+ expect(needsCurrencyReview(suggested)).toBe(true);
+ expect(currencyToRemember(suggested)).toBeNull();
+ expect(draft.currency).toBe(UNKNOWN_CURRENCY);
+ });
+
+ it("never overrides an extracted currency with the previous one", () => {
+ const draft = createEmptyDraft("USD");
+ expect(applyCurrencyPreference(draft, "CAD")).toBe(draft);
+ expect(currencyToRemember(draft)).toBe("USD");
+ expect(applyCurrencyPreference(createEmptyDraft(), "invalid").currency).toBe(UNKNOWN_CURRENCY);
+ });
+
+ it("clears only currency warnings after explicit confirmation", () => {
+ const draft = applyCurrencyPreference(createEmptyDraft(), "CAD");
+ draft.uncertaintyFields = ["currency", "/currency", "items[0].sku"];
+ const reviewed = confirmCurrency(draft, "CAD");
+ expect(needsCurrencyReview(reviewed)).toBe(false);
+ expect(reviewed.fieldSources.currency).toBe("user_entered");
+ expect(reviewed.uncertaintyFields).toEqual(["items[0].sku"]);
+ expect(currencyToRemember(reviewed)).toBe("CAD");
+ expect(needsCurrencyReview(confirmCurrency(reviewed, UNKNOWN_CURRENCY))).toBe(true);
+ });
+
+ it("keeps the uncertainty limit intact when adding a currency suggestion", () => {
+ const draft = createEmptyDraft();
+ draft.uncertaintyFields = Array.from({ length: 100 }, (_, index) => `items[${index}].sku`);
+ const suggested = applyCurrencyPreference(draft, "EUR");
+ expect(() => receiptDraftSchema.parse(suggested)).not.toThrow();
+ expect(needsCurrencyReview(suggested)).toBe(true);
+ });
+
+ it("persists the last reviewed currency for subsequent sessions", () => {
+ const values = new Map();
+ vi.stubGlobal("localStorage", {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => { values.set(key, value); }
+ });
+ expect(readLastCurrency()).toBeNull();
+ writeLastCurrency("CAD");
+ expect(readLastCurrency()).toBe("CAD");
+ writeLastCurrency(UNKNOWN_CURRENCY);
+ expect(readLastCurrency()).toBe("CAD");
+ writeLastCurrency("EUR");
+ expect(applyCurrencyPreference(createEmptyDraft(), readLastCurrency()).currency).toBe("EUR");
+ });
+
+ it("ignores corrupt preferences and tolerates unavailable local storage", () => {
+ vi.stubGlobal("localStorage", { getItem: () => "null" });
+ expect(readLastCurrency()).toBeNull();
+ vi.stubGlobal("localStorage", {
+ getItem: () => { throw new Error("Storage blocked"); },
+ setItem: () => { throw new Error("Storage full"); }
+ });
+ expect(readLastCurrency()).toBeNull();
+ expect(() => writeLastCurrency("CAD")).not.toThrow();
+ });
+});
diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts
index ee8e775..07895f9 100644
--- a/tests/extraction.test.ts
+++ b/tests/extraction.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { ManualExtractor, OpenAICompatibleExtractor } from "../server/extraction.js";
import { parsePastedReceiptJson, ReceiptImportError } from "../shared/receipt-import.js";
import { calculateReceipt } from "../shared/receipt.js";
+import { needsCurrencyReview, UNKNOWN_CURRENCY } from "../shared/currency.js";
afterEach(() => vi.unstubAllGlobals());
@@ -21,6 +22,32 @@ describe("extraction adapters", () => {
});
describe("pasted ChatGPT receipt JSON", () => {
+ it.each([null, "", "not visible", 123, {}, undefined])("imports a missing or malformed currency (%j) as an incomplete draft", (currency) => {
+ const content = JSON.stringify({ currency, totalMinor: 525, items: [{ lineTotalMinor: 525 }] });
+ const { draft, rawResult } = parsePastedReceiptJson(content);
+ expect(rawResult).toEqual(JSON.parse(content));
+ expect(draft.currency).toBe(UNKNOWN_CURRENCY);
+ expect(draft.totalMinor).toBe(525);
+ expect(draft.uncertaintyFields).toContain("currency");
+ expect(needsCurrencyReview(draft)).toBe(true);
+ });
+
+ it("normalizes lowercase currency codes without rewriting raw AI output", () => {
+ const { draft, rawResult } = parsePastedReceiptJson('{"currency":" cad "}');
+ expect(draft.currency).toBe("CAD");
+ expect(rawResult).toEqual({ currency: " cad " });
+ expect(needsCurrencyReview(draft)).toBe(false);
+ });
+
+ it("accepts a missing currency alongside the maximum number of uncertainty paths", () => {
+ const { draft } = parsePastedReceiptJson(JSON.stringify({
+ currency: null,
+ uncertaintyFields: Array.from({ length: 100 }, (_, index) => `items[${index}].sku`)
+ }));
+ expect(draft.uncertaintyFields).toHaveLength(100);
+ expect(needsCurrencyReview(draft)).toBe(true);
+ });
+
it("accepts a fenced JSON object and normalizes it into an unverified draft", () => {
const content = `\`\`\`json
{
diff --git a/tests/native-api.test.ts b/tests/native-api.test.ts
new file mode 100644
index 0000000..005a56c
--- /dev/null
+++ b/tests/native-api.test.ts
@@ -0,0 +1,366 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { DatabaseSync } from "node:sqlite";
+import { createEmptyDraft, createEmptyItem, createUuid } from "../shared/receipt.js";
+import { applyCurrencyPreference, confirmCurrency } from "../shared/currency.js";
+import { parseNativeBackup } from "../shared/native-backup.js";
+
+type TestRow = {
+ id: string;
+ client_mutation_id: string;
+ status: string;
+ scanned_at: string;
+ confirmed_at: string | null;
+ draft_json: string;
+ attachment_json: string | null;
+ image_sha256: string | null;
+ extraction_json: string | null;
+};
+
+const state = vi.hoisted(() => ({
+ db: null as DatabaseSync | null,
+ records: [] as TestRow[],
+ images: new Map(),
+ failImageWrite: false
+}));
+
+vi.mock("@capacitor-community/sqlite", async () => ({
+ CapacitorSQLite: {},
+ SQLiteConnection: class {
+ async createConnection() {
+ const { DatabaseSync } = await import("node:sqlite");
+ const db = new DatabaseSync(":memory:");
+ db.exec("PRAGMA foreign_keys = ON;");
+ state.db = db;
+ function refresh() { state.records = db.prepare("SELECT * FROM receipts").all() as TestRow[]; }
+ return {
+ open: async () => undefined,
+ execute: async (sql: string) => { db.exec(sql); },
+ query: async (sql: string, values: string[] = []) => {
+ return { values: db.prepare(sql).all(...values) };
+ },
+ run: async (sql: string, values: (string | number | null)[]) => {
+ const result = db.prepare(sql).run(...values);
+ refresh();
+ return { changes: { changes: Number(result.changes) } };
+ },
+ beginTransaction: async () => { db.exec("BEGIN IMMEDIATE"); },
+ commitTransaction: async () => { db.exec("COMMIT"); refresh(); },
+ rollbackTransaction: async () => { db.exec("ROLLBACK"); refresh(); }
+ };
+ }
+ }
+}));
+
+vi.mock("@capacitor/filesystem", () => ({
+ Directory: { Data: "DATA" },
+ Filesystem: {
+ readdir: async () => ({ files: [...state.images.keys()].map((path) => ({ name: path.split("/").at(-1) })) }),
+ deleteFile: async ({ path }: { path: string }) => { state.images.delete(path); },
+ stat: async ({ path }: { path: string }) => {
+ if (!state.images.has(path)) throw new Error("File not found");
+ return { size: state.images.get(path)!.length };
+ },
+ readFile: async ({ path }: { path: string }) => {
+ const data = state.images.get(path);
+ if (!data) throw new Error("File not found");
+ return { data };
+ },
+ writeFile: async ({ path, data }: { path: string; data: string }) => {
+ if (state.failImageWrite) throw new Error("Disk full");
+ state.images.set(path, data);
+ }
+ }
+}));
+
+import * as nativeApi from "../src/native/api.js";
+
+beforeEach(() => {
+ state.db?.exec("DELETE FROM receipt_audit_events; DELETE FROM receipt_mutations; DELETE FROM receipts;");
+ state.records.length = 0;
+ state.images.clear();
+});
+
+describe("phone-local receipt adapter", () => {
+ async function createBackupFixture() {
+ const bytes = new TextEncoder().encode("backup-image");
+ const file = { name: "receipt.jpg", type: "image/jpeg", size: bytes.byteLength, arrayBuffer: async () => bytes.buffer } as unknown as File;
+ const imported = await nativeApi.importChatGptReceipt('{"merchantName":"Original shop","currency":"CAD","totalMinor":250,"items":[{"rawName":"Eggs","lineTotalMinor":250,"quantity":"1.000001","uncertainties":["sku"]}]}', file);
+ const saved = await nativeApi.saveReceipt({ clientMutationId: createUuid(), status: "confirmed", draft: imported.draft, attachment: imported.attachment, extractionId: imported.extraction.id });
+ await nativeApi.updateReceipt(saved.id, { clientMutationId: createUuid(), expectedRevision: 1, status: "confirmed", draft: { ...saved.receipt.draft, notes: "Reviewed" } });
+ await nativeApi.changeReceiptDeleted(saved.id, { clientMutationId: createUuid(), expectedRevision: 2 }, true);
+ await nativeApi.saveReceipt({ clientMutationId: createUuid(), status: "draft", draft: createEmptyDraft(), attachment: null, extractionId: null });
+ return nativeApi.createNativeBackup({ lastCurrency: "CAD", theme: "dark" });
+ }
+
+ function clearDatabase() {
+ state.db!.exec("DELETE FROM receipt_audit_events; DELETE FROM receipt_mutations; DELETE FROM receipts;");
+ state.images.clear();
+ }
+
+ it("round-trips complete backups including trash, exact quantities, original extraction and audit history", async () => {
+ const backup = await createBackupFixture();
+ const original = structuredClone(backup);
+ clearDatabase();
+ const validated = await parseNativeBackup(JSON.stringify(backup));
+ await expect(nativeApi.restoreNativeBackup(validated)).resolves.toEqual({ lastCurrency: "CAD", theme: "dark" });
+ const restored = await nativeApi.createNativeBackup(original.preferences);
+ expect({ ...restored, exportedAt: original.exportedAt }).toEqual(original);
+ expect((await nativeApi.loadHistory()).receipts).toHaveLength(1);
+ expect((await nativeApi.loadHistory("", true)).receipts).toHaveLength(1);
+ const archived = original.receipts.find((row) => row.deleted_at)!;
+ expect((await nativeApi.loadReceipt(archived.id)).receipt.draft.items[0].quantity).toBe("1.000001");
+ await nativeApi.changeReceiptDeleted(archived.id, { clientMutationId: createUuid(), expectedRevision: 3 }, false);
+ expect((await nativeApi.loadHistory()).receipts).toHaveLength(2);
+ });
+
+ it("refuses restore into an installation containing only trash and leaves it untouched", async () => {
+ const backup = await createBackupFixture();
+ const before = JSON.stringify(state.records);
+ await expect(nativeApi.restoreNativeBackup(backup)).rejects.toThrow("leeren Datenbank");
+ expect(JSON.stringify(state.records)).toBe(before);
+ expect(state.images.size).toBe(1);
+ });
+
+ it("validates backup images, identities, relations, schema version and confirmed arithmetic before writing", async () => {
+ const backup = await createBackupFixture();
+ clearDatabase();
+ const corrupted = structuredClone(backup); corrupted.images[0].dataBase64 = btoa("tampered");
+ await expect(nativeApi.restoreNativeBackup(corrupted)).rejects.toThrow("Prüfsumme");
+ const missing = structuredClone(backup); missing.images = [];
+ await expect(nativeApi.restoreNativeBackup(missing)).rejects.toThrow("Bonbild");
+ const duplicate = structuredClone(backup); duplicate.receipts.push(duplicate.receipts[0]);
+ await expect(nativeApi.restoreNativeBackup(duplicate)).rejects.toThrow("doppelte");
+ const dangling = structuredClone(backup); dangling.mutations[0].receipt_id = createUuid();
+ await expect(nativeApi.restoreNativeBackup(dangling)).rejects.toThrow("Änderungsverlauf");
+ const unbalanced = structuredClone(backup); const receipt = unbalanced.receipts.find((row) => row.status === "confirmed")!;
+ receipt.draft_json = JSON.stringify({ ...JSON.parse(receipt.draft_json), totalMinor: 999 });
+ await expect(nativeApi.restoreNativeBackup(unbalanced)).rejects.toThrow("ausgeglichen");
+ const traversal = structuredClone(backup); traversal.images[0].token = "../../secret";
+ await expect(nativeApi.restoreNativeBackup(traversal)).rejects.toThrow("unterstützte");
+ await expect(parseNativeBackup("not json")).rejects.toThrow("gültiges JSON");
+ await expect(nativeApi.restoreNativeBackup({ ...backup, version: 2 })).rejects.toThrow("unterstützte");
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipts").get()).toMatchObject({ count: 0 });
+ expect(state.images.size).toBe(0);
+ });
+
+ it("rolls back all restored rows and cleans up newly written images if audit insertion fails", async () => {
+ const backup = await createBackupFixture();
+ clearDatabase();
+ state.db!.exec("CREATE TEMP TRIGGER fail_restore BEFORE INSERT ON receipt_audit_events BEGIN SELECT RAISE(ABORT, 'Restore failed'); END;");
+ try {
+ await expect(nativeApi.restoreNativeBackup(backup)).rejects.toThrow("Restore failed");
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipts").get()).toMatchObject({ count: 0 });
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipt_mutations").get()).toMatchObject({ count: 0 });
+ expect(state.images.size).toBe(0);
+ } finally { state.db!.exec("DROP TRIGGER fail_restore;"); }
+ await expect(nativeApi.restoreNativeBackup(backup)).resolves.toHaveProperty("lastCurrency", "CAD");
+ });
+
+ it("keeps a failed image restore empty and allows retry", async () => {
+ const backup = await createBackupFixture();
+ clearDatabase();
+ state.failImageWrite = true;
+ try {
+ await expect(nativeApi.restoreNativeBackup(backup)).rejects.toThrow("Disk full");
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipts").get()).toMatchObject({ count: 0 });
+ } finally { state.failImageWrite = false; }
+ await expect(nativeApi.restoreNativeBackup(backup)).resolves.toHaveProperty("lastCurrency", "CAD");
+ });
+
+ it("serializes simultaneous restores and rejects the second without duplicating receipts", async () => {
+ const backup = await createBackupFixture();
+ clearDatabase();
+ const results = await Promise.allSettled([nativeApi.restoreNativeBackup(backup), nativeApi.restoreNativeBackup(backup)]);
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipts").get()).toMatchObject({ count: 2 });
+ });
+
+ it("will not export a backup with a missing or damaged image", async () => {
+ await createBackupFixture();
+ const path = [...state.images.keys()][0];
+ state.images.set(path, btoa("broken"));
+ await expect(nativeApi.createNativeBackup({ lastCurrency: null, theme: null })).rejects.toThrow("Prüfsumme");
+ state.images.delete(path);
+ await expect(nativeApi.createNativeBackup({ lastCurrency: null, theme: null })).rejects.toThrow("Bonbild fehlt");
+ });
+
+ it("repairs an interrupted orphan image on an otherwise empty restore target", async () => {
+ const backup = await createBackupFixture();
+ clearDatabase();
+ state.images.set(`receipts/${backup.images[0].token}`, btoa("partial"));
+ await expect(nativeApi.restoreNativeBackup(backup)).resolves.toHaveProperty("lastCurrency", "CAD");
+ expect(state.images.get(`receipts/${backup.images[0].token}`)).toBe(backup.images[0].dataBase64);
+ });
+
+ it("serializes concurrent updates and accepts only one edit of a revision", async () => {
+ const draft = createEmptyDraft("CAD");
+ const saved = await nativeApi.saveReceipt({ clientMutationId: createUuid(), status: "draft", draft, attachment: null, extractionId: null });
+ const edits = ["first", "second"].map((notes) => nativeApi.updateReceipt(saved.id, {
+ clientMutationId: createUuid(), expectedRevision: 1, status: "draft", draft: { ...draft, notes }
+ }));
+ const results = await Promise.allSettled(edits);
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
+ expect((await nativeApi.loadReceipt(saved.id)).receipt).toMatchObject({ revision: 2, draft: { notes: "first" } });
+ });
+ it("updates item CRUD without duplicating the receipt and rejects stale edits", async () => {
+ const imported = await nativeApi.importChatGptReceipt('{"currency":"CAD","totalMinor":100,"items":[{"rawName":"ORIGINAL","lineTotalMinor":100}]}', null);
+ const saved = await nativeApi.saveReceipt({ clientMutationId: createUuid(), status: "confirmed", draft: imported.draft, attachment: null, extractionId: imported.extraction.id });
+ const originalExtraction = state.records[0].extraction_json;
+ const added = createEmptyItem(2);
+ added.lineTotalMinor = 200;
+ const draft = { ...saved.receipt.draft, merchantName: "Corrected shop", totalMinor: 300, items: [{ ...saved.receipt.draft.items[0], normalizedName: "Updated" }, added] };
+ const input = { clientMutationId: createUuid(), expectedRevision: 1, status: "confirmed" as const, draft };
+ const changed = await nativeApi.updateReceipt(saved.id, input);
+ await nativeApi.updateReceipt(saved.id, input);
+ expect(changed.receipt).toMatchObject({ revision: 2, scannedAt: saved.receipt.scannedAt, confirmedAt: saved.receipt.confirmedAt });
+ expect(changed.receipt.draft.items).toHaveLength(2);
+ expect(state.records).toHaveLength(1);
+ expect(state.records[0].extraction_json).toBe(originalExtraction);
+ await expect(nativeApi.updateReceipt(saved.id, { ...input, clientMutationId: createUuid() })).rejects.toThrow("erneut");
+ await expect(nativeApi.updateReceipt(saved.id, { ...input, clientMutationId: createUuid(), expectedRevision: 2, draft: { ...draft, totalMinor: 400 } })).rejects.toThrow("ausgeglichener");
+ expect((await nativeApi.loadReceipt(saved.id)).receipt.revision).toBe(2);
+ const result = await nativeApi.updateReceipt(saved.id, { ...input, clientMutationId: createUuid(), expectedRevision: 2, draft: { ...draft, items: [added], totalMinor: 200 } });
+ expect(result.receipt.draft.items.map((item) => item.id)).toEqual([added.id]);
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipt_audit_events").get()).toMatchObject({ count: 2 });
+ });
+
+ it("moves receipts to trash and restores them with their original image", async () => {
+ const bytes = new TextEncoder().encode("crud-test-image");
+ const file = { name: "receipt.jpg", type: "image/jpeg", size: bytes.byteLength, arrayBuffer: async () => bytes.buffer } as unknown as File;
+ const attachment = await nativeApi.prepareReceiptImage(file);
+ const draft = createEmptyDraft("CAD");
+ const item = createEmptyItem(1); item.lineTotalMinor = 100;
+ draft.items = [item]; draft.totalMinor = 100;
+ const saved = await nativeApi.saveReceipt({ clientMutationId: createUuid(), status: "confirmed", draft, attachment, extractionId: null });
+ const mutation = { clientMutationId: createUuid(), expectedRevision: 1 };
+ const deleted = await nativeApi.changeReceiptDeleted(saved.id, mutation, true);
+ await nativeApi.changeReceiptDeleted(saved.id, mutation, true);
+ expect(deleted.receipt.revision).toBe(2);
+ expect((await nativeApi.loadHistory()).receipts).toHaveLength(0);
+ expect((await nativeApi.loadHistory("", true)).receipts).toHaveLength(1);
+ expect(state.images.size).toBe(1);
+ await expect(nativeApi.updateReceipt(saved.id, { clientMutationId: createUuid(), expectedRevision: 2, status: "confirmed", draft })).rejects.toThrow("gelöscht");
+ await expect(nativeApi.changeReceiptDeleted(saved.id, { clientMutationId: createUuid(), expectedRevision: 1 }, false)).rejects.toThrow("erneut");
+ const restore = { clientMutationId: createUuid(), expectedRevision: 2 };
+ const restored = await nativeApi.changeReceiptDeleted(saved.id, restore, false);
+ await nativeApi.changeReceiptDeleted(saved.id, restore, false);
+ expect(restored.receipt).toMatchObject({ revision: 3, deletedAt: null, draft });
+ expect(restored.receipt.attachment?.sha256).toBe(attachment.sha256);
+ expect((await nativeApi.loadHistory()).receipts).toHaveLength(1);
+ expect((await nativeApi.loadHistory("", true)).receipts).toHaveLength(0);
+ });
+
+ it("rolls back an update when its audit record cannot be committed", async () => {
+ const draft = createEmptyDraft("CAD");
+ const saved = await nativeApi.saveReceipt({ clientMutationId: createUuid(), status: "draft", draft, attachment: null, extractionId: null });
+ state.db!.exec("CREATE TEMP TRIGGER fail_audit BEFORE INSERT ON receipt_audit_events BEGIN SELECT RAISE(ABORT, 'Test audit failure'); END;");
+ const input = { clientMutationId: createUuid(), expectedRevision: 1, status: "draft" as const, draft: { ...draft, notes: "Must roll back" } };
+ try {
+ await expect(nativeApi.updateReceipt(saved.id, input)).rejects.toThrow("Test audit failure");
+ expect((await nativeApi.loadReceipt(saved.id)).receipt).toMatchObject({ revision: 1, draft: { notes: "" } });
+ expect(state.db!.prepare("SELECT COUNT(*) AS count FROM receipt_mutations").get()).toMatchObject({ count: 0 });
+ } finally { state.db!.exec("DROP TRIGGER fail_audit;"); }
+ await expect(nativeApi.updateReceipt(saved.id, input)).resolves.toHaveProperty("receipt.revision", 2);
+ });
+ it("preserves unreviewed currency drafts and rejects confirmation until reviewed", async () => {
+ const item = createEmptyItem(1);
+ item.lineTotalMinor = 100;
+ const draft = createEmptyDraft();
+ draft.items = [item];
+ draft.totalMinor = 100;
+ const input = { clientMutationId: createUuid(), status: "confirmed" as const, draft, attachment: null, extractionId: null };
+ await expect(nativeApi.saveReceipt(input)).rejects.toThrow("Währung");
+ const suggestion = applyCurrencyPreference(draft, "EUR");
+ const saved = await nativeApi.saveReceipt({ ...input, status: "draft", draft: suggestion });
+ const loaded = (await nativeApi.loadReceipt(saved.id)).receipt.draft;
+ expect(loaded.fieldSources.currency).toBe("uncertain");
+ await expect(nativeApi.saveReceipt({ ...input, clientMutationId: createUuid(), draft: loaded })).rejects.toThrow("Währung");
+ await expect(nativeApi.saveReceipt({ ...input, clientMutationId: createUuid(), draft: confirmCurrency(loaded, "EUR") })).resolves.toHaveProperty("id");
+ });
+ it("saves balanced receipts locally and makes retries idempotent", async () => {
+ state.records.length = 0;
+ await expect(nativeApi.loadConfig()).resolves.toMatchObject({ extractionMode: "manual" });
+ const draft = createEmptyDraft("CAD");
+ const item = createEmptyItem(1);
+ item.normalizedName = "Eggs";
+ item.lineTotalMinor = 299;
+ draft.merchantName = "Shop";
+ draft.items = [item];
+ draft.totalMinor = 299;
+ const input = { clientMutationId: createUuid(), status: "confirmed" as const, draft, attachment: null, extractionId: null };
+
+ const first = await nativeApi.saveReceipt(input);
+ const retry = await nativeApi.saveReceipt(input);
+ expect(retry.id).toBe(first.id);
+ expect(state.records).toHaveLength(1);
+ await expect(nativeApi.loadReceipt(first.id)).resolves.toMatchObject({ receipt: { draft: { merchantName: "Shop" } } });
+ await expect(nativeApi.loadHistory("egg")).resolves.toMatchObject({ receipts: [{ id: first.id }] });
+
+ const unbalanced = { ...input, clientMutationId: createUuid(), draft: { ...draft, totalMinor: 300 } };
+ await expect(nativeApi.saveReceipt(unbalanced)).rejects.toThrow("nicht ausgeglichener Bon");
+ expect(state.records).toHaveLength(1);
+ });
+
+ it("copies a picked image before import and never reads the picker file again", async () => {
+ state.records.length = 0;
+ state.images.clear();
+ const bytes = new TextEncoder().encode("receipt-photo");
+ let reads = 0;
+ const file = {
+ name: "receipt.jpg",
+ type: "image/jpeg",
+ size: bytes.byteLength,
+ arrayBuffer: async () => {
+ reads += 1;
+ if (reads > 1) throw new Error("Picker access expired");
+ return bytes.buffer;
+ }
+ } as unknown as File;
+ const prepared = await nativeApi.prepareReceiptImage(file);
+ expect(reads).toBe(1);
+ expect(state.images.has(`receipts/${prepared.token}`)).toBe(true);
+
+ const imported = await nativeApi.importChatGptReceipt('{"merchantName":"Shop","currency":"CAD"}', file);
+ expect(imported.attachment?.token).toBe(prepared.token);
+ const draft = imported.draft;
+ const item = createEmptyItem(1);
+ item.lineTotalMinor = 250;
+ draft.items = [item];
+ draft.totalMinor = 250;
+ const saved = await nativeApi.saveReceipt({
+ clientMutationId: createUuid(), status: "confirmed", draft,
+ attachment: imported.attachment, extractionId: imported.extraction.id
+ });
+ expect(reads).toBe(1);
+ expect(saved.receipt.attachment?.sha256).toBe(prepared.sha256);
+ expect(state.records).toHaveLength(1);
+ });
+
+ it("keeps JSON-only import available when the image picker cannot be read", async () => {
+ const unreadable = {
+ name: "receipt.jpg", type: "image/jpeg", size: 100,
+ arrayBuffer: async () => { throw new Error("Picker access expired"); }
+ } as unknown as File;
+ await expect(nativeApi.prepareReceiptImage(unreadable)).rejects.toThrow("Bonbild ist nicht lesbar");
+ const result = await nativeApi.importChatGptReceipt('{"merchantName":"Shop","currency":"CAD"}', null);
+ expect(result.draft.merchantName).toBe("Shop");
+ expect(result.attachment).toBeNull();
+ });
+
+ it("does not accept an image that could not be copied into private storage", async () => {
+ state.records.length = 0;
+ state.failImageWrite = true;
+ const bytes = new TextEncoder().encode("another-receipt-photo");
+ const file = {
+ name: "receipt.jpg", type: "image/jpeg", size: bytes.byteLength,
+ arrayBuffer: async () => bytes.buffer
+ } as unknown as File;
+ try {
+ await expect(nativeApi.prepareReceiptImage(file)).rejects.toThrow("Disk full");
+ expect(state.records).toHaveLength(0);
+ } finally {
+ state.failImageWrite = false;
+ }
+ });
+});
diff --git a/tests/native-backup-documents.test.ts b/tests/native-backup-documents.test.ts
new file mode 100644
index 0000000..1a598d8
--- /dev/null
+++ b/tests/native-backup-documents.test.ts
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { NativeBackup } from "../shared/native-backup.js";
+
+const state = vi.hoisted(() => ({
+ documents: { save: vi.fn(), open: vi.fn() },
+ fs: { writeFile: vi.fn(), readFile: vi.fn(), deleteFile: vi.fn() },
+ create: vi.fn(), restore: vi.fn(), preference: "CAD" as string | null
+}));
+vi.mock("@capacitor/core", () => ({ registerPlugin: () => state.documents }));
+vi.mock("@capacitor/filesystem", () => ({ Directory: { Cache: "CACHE" }, Encoding: { UTF8: "utf8" }, Filesystem: state.fs }));
+vi.mock("../src/native/api.js", () => ({ createNativeBackup: state.create, restoreNativeBackup: state.restore }));
+vi.mock("../src/currency-preference.js", () => ({ readLastCurrency: () => state.preference }));
+import { exportPhoneBackup, importPhoneBackup, selectPhoneBackup } from "../src/native/backup.js";
+
+function fixture(): NativeBackup {
+ return {
+ format: "itemly-android-backup", version: 1, exportedAt: "2026-10-06T00:00:00.000Z",
+ receipts: [], mutations: [], auditEvents: [], images: [], preferences: { lastCurrency: "CAD", theme: "dark" }
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ state.documents.save.mockResolvedValue({ cancelled: false });
+ state.documents.open.mockResolvedValue({ cancelled: true });
+ state.fs.deleteFile.mockResolvedValue(undefined);
+ state.fs.writeFile.mockResolvedValue(undefined);
+ state.create.mockResolvedValue(fixture());
+ state.restore.mockResolvedValue(fixture().preferences);
+ vi.stubGlobal("document", { documentElement: { dataset: { theme: "dark" } } });
+ vi.stubGlobal("localStorage", { setItem: vi.fn(), removeItem: vi.fn() });
+});
+
+describe("user-initiated native backup document bridge", () => {
+ it("exports through the Android document picker and deletes its private temporary file", async () => {
+ await expect(exportPhoneBackup()).resolves.toBe(true);
+ expect(state.create).toHaveBeenCalledWith({ lastCurrency: "CAD", theme: "dark" });
+ const written = state.fs.writeFile.mock.calls[0][0];
+ expect(written).toMatchObject({ directory: "CACHE", encoding: "utf8" });
+ expect(JSON.parse(written.data)).toEqual(fixture());
+ expect(state.documents.save).toHaveBeenCalledWith({ cacheName: written.path });
+ expect(state.fs.deleteFile).toHaveBeenCalledWith({ path: written.path, directory: "CACHE" });
+ });
+ it("does not report success when export is cancelled and cleans up failures", async () => {
+ state.documents.save.mockResolvedValueOnce({ cancelled: true });
+ await expect(exportPhoneBackup()).resolves.toBe(false);
+ state.documents.save.mockRejectedValueOnce(new Error("Provider unavailable"));
+ await expect(exportPhoneBackup()).rejects.toThrow("Provider unavailable");
+ expect(state.fs.deleteFile).toHaveBeenCalledTimes(2);
+ });
+ it("does not read a file when import is cancelled", async () => {
+ await expect(selectPhoneBackup()).resolves.toBeNull();
+ expect(state.fs.readFile).not.toHaveBeenCalled();
+ expect(state.restore).not.toHaveBeenCalled();
+ });
+ it("validates the selected file before presenting a preview and always clears its cache copy", async () => {
+ const cacheName = "itemly-backup-00000000-0000-4000-8000-000000000000.json";
+ state.documents.open.mockResolvedValue({ cancelled: false, cacheName });
+ state.fs.readFile.mockResolvedValueOnce({ data: JSON.stringify(fixture()) });
+ await expect(selectPhoneBackup()).resolves.toEqual(fixture());
+ expect(state.restore).not.toHaveBeenCalled();
+ state.fs.readFile.mockResolvedValueOnce({ data: "broken" });
+ await expect(selectPhoneBackup()).rejects.toThrow("gültiges JSON");
+ expect(state.fs.deleteFile).toHaveBeenCalledTimes(2);
+ });
+ it("refuses unexpected cache paths returned by the bridge", async () => {
+ state.documents.open.mockResolvedValue({ cancelled: false, cacheName: "../../database" });
+ await expect(selectPhoneBackup()).rejects.toThrow("nicht verfügbar");
+ expect(state.fs.readFile).not.toHaveBeenCalled();
+ });
+ it("writes preferences only after a successful database restore and treats preference failure as a warning", async () => {
+ state.restore.mockRejectedValueOnce(new Error("Database not empty"));
+ await expect(importPhoneBackup(fixture())).rejects.toThrow("Database not empty");
+ expect(localStorage.setItem).not.toHaveBeenCalled();
+ await expect(importPhoneBackup(fixture())).resolves.toEqual({ preferenceWarning: false });
+ expect(localStorage.setItem).toHaveBeenCalledWith("itemly-last-currency", "CAD");
+ vi.mocked(localStorage.setItem).mockImplementation(() => { throw new Error("Quota exceeded"); });
+ await expect(importPhoneBackup(fixture())).resolves.toEqual({ preferenceWarning: true });
+ });
+ it("restores absent preferences without retaining stale currency suggestions", async () => {
+ state.restore.mockResolvedValue({ lastCurrency: null, theme: null });
+ await importPhoneBackup(fixture());
+ expect(localStorage.removeItem).toHaveBeenCalledWith("itemly-last-currency");
+ expect(localStorage.removeItem).toHaveBeenCalledWith("itemly-theme");
+ });
+ it("does not write or launch the picker for an oversized backup", async () => {
+ // Exercise the byte-size boundary without allocating a 100 MB fixture.
+ const encode = vi.spyOn(TextEncoder.prototype, "encode").mockReturnValueOnce({ byteLength: 100 * 1024 * 1024 + 1 } as Uint8Array);
+ try {
+ await expect(exportPhoneBackup()).rejects.toThrow("100 MB");
+ expect(state.documents.save).not.toHaveBeenCalled();
+ expect(state.fs.writeFile).not.toHaveBeenCalled();
+ } finally { encode.mockRestore(); }
+ });
+});
diff --git a/tests/native-image-picker.test.ts b/tests/native-image-picker.test.ts
new file mode 100644
index 0000000..b4aa808
--- /dev/null
+++ b/tests/native-image-picker.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ galleryResult: { results: [] as unknown[] },
+ readData: "",
+ readError: false
+}));
+
+vi.mock("@capacitor/camera", () => ({
+ Camera: {
+ takePhoto: vi.fn(),
+ chooseFromGallery: vi.fn(async () => state.galleryResult)
+ },
+ MediaTypeSelection: { Photo: 0 }
+}));
+
+vi.mock("@capacitor/filesystem", () => ({
+ Filesystem: {
+ readFile: vi.fn(async () => {
+ if (state.readError) throw new Error("stale reference");
+ return { data: state.readData };
+ })
+ }
+}));
+
+import { Camera } from "@capacitor/camera";
+import { Filesystem } from "@capacitor/filesystem";
+import { mediaResultToFile, selectNativeImage } from "../src/native/image-picker.js";
+
+describe("native image picker", () => {
+ it("reads the native URI into an independent in-memory image", async () => {
+ state.readData = btoa("receipt bytes");
+ state.galleryResult = { results: [{ uri: "file:///cache/receipt.jpg", metadata: { format: "jpg", size: 13 } }] };
+ const file = await selectNativeImage("gallery");
+ expect(Camera.chooseFromGallery).toHaveBeenCalledWith(expect.objectContaining({ allowMultipleSelection: false, includeMetadata: true }));
+ expect(Filesystem.readFile).toHaveBeenCalledWith({ path: "file:///cache/receipt.jpg" });
+ expect(file?.type).toBe("image/jpeg");
+ expect(await file?.text()).toBe("receipt bytes");
+ });
+
+ it("treats a canceled gallery selection as no image", async () => {
+ state.galleryResult = { results: [] };
+ await expect(selectNativeImage("gallery")).resolves.toBeNull();
+ });
+
+ it("shows a local error when the native URI cannot be read", async () => {
+ state.readError = true;
+ await expect(mediaResultToFile({ uri: "file:///cache/missing.jpg", metadata: { format: "jpg" } } as Parameters[0]))
+ .rejects.toThrow("konnte nicht gelesen werden");
+ state.readError = false;
+ });
+
+ it("rejects oversized media before reading", async () => {
+ await expect(mediaResultToFile({ uri: "file:///cache/large.jpg", metadata: { format: "jpg", size: 16 * 1024 * 1024 } } as Parameters[0]))
+ .rejects.toThrow("höchstens 15 MB");
+ });
+});
diff --git a/tests/native-migration.test.ts b/tests/native-migration.test.ts
new file mode 100644
index 0000000..f8a1c6f
--- /dev/null
+++ b/tests/native-migration.test.ts
@@ -0,0 +1,39 @@
+import { DatabaseSync } from "node:sqlite";
+import { readFileSync } from "node:fs";
+import { describe, expect, it } from "vitest";
+
+const initialMigration = readFileSync(new URL("../src/native/migrations/001_initial.sql", import.meta.url), "utf8");
+const crudMigration = readFileSync(new URL("../src/native/migrations/002_receipt_crud.sql", import.meta.url), "utf8");
+
+describe("Android SQLite migration", () => {
+ it("upgrades existing receipts without dropping drafts or image references", () => {
+ const db = new DatabaseSync(":memory:");
+ try {
+ db.exec(initialMigration);
+ db.prepare("INSERT INTO receipts(id, client_mutation_id, status, scanned_at, draft_json, attachment_json) VALUES (?, ?, ?, ?, ?, ?)")
+ .run("legacy", "old-mutation", "draft", "2026-09-26", '{"currency":"CAD"}', '{"token":"original"}');
+ db.exec(crudMigration);
+ expect(db.prepare("PRAGMA user_version").get()).toMatchObject({ user_version: 2 });
+ expect(db.prepare("SELECT * FROM receipts WHERE id = 'legacy'").get()).toMatchObject({ revision: 1, deleted_at: null, draft_json: '{"currency":"CAD"}', attachment_json: '{"token":"original"}' });
+ } finally { db.close(); }
+ });
+ it("creates versioned, idempotent receipt storage with a unique mutation ID", () => {
+ const db = new DatabaseSync(":memory:");
+ try {
+ db.exec(initialMigration);
+ db.exec(initialMigration);
+ const version = db.prepare("PRAGMA user_version").get() as { user_version: number };
+ expect(version.user_version).toBe(1);
+ const insert = db.prepare(`INSERT INTO receipts
+ (id, client_mutation_id, status, scanned_at, draft_json)
+ VALUES (?, ?, ?, ?, ?)`);
+ insert.run("one", "mutation-1", "draft", "2026-09-26T00:00:00Z", "{}");
+ expect(() => insert.run("two", "mutation-1", "draft", "2026-09-26T00:00:00Z", "{}"))
+ .toThrow();
+ expect(() => insert.run("three", "mutation-2", "invalid", "2026-09-26T00:00:00Z", "{}"))
+ .toThrow();
+ } finally {
+ db.close();
+ }
+ });
+});
diff --git a/tests/receipt-edit.test.ts b/tests/receipt-edit.test.ts
new file mode 100644
index 0000000..3fb668b
--- /dev/null
+++ b/tests/receipt-edit.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { createEmptyDraft, createEmptyItem } from "../shared/receipt.js";
+import { replaceReceiptItems } from "../shared/receipt-edit.js";
+
+describe("item edits and provenance", () => {
+ it("moves warning paths with stable item IDs instead of leaving stale array indices", () => {
+ const draft = createEmptyDraft("CAD");
+ draft.items = [createEmptyItem(1), createEmptyItem(2)];
+ draft.fieldSources = { "items[0].sku": "uncertain", "items.1.quantity": "uncertain", "/items/1/brand": "derived", currency: "extracted" };
+ draft.uncertaintyFields = ["items[0].sku", "items.1.quantity", "/items/1/sku", "currency"];
+ const removed = replaceReceiptItems(draft, [draft.items[1]]);
+ expect(removed.fieldSources).toEqual({ "items.0.quantity": "uncertain", "/items/0/brand": "derived", currency: "extracted" });
+ expect(removed.uncertaintyFields).toEqual(["items.0.quantity", "/items/0/sku", "currency"]);
+ const restored = replaceReceiptItems(removed, draft.items, draft);
+ expect(restored.fieldSources).toEqual(draft.fieldSources);
+ expect(new Set(restored.uncertaintyFields)).toEqual(new Set(draft.uncertaintyFields));
+ });
+});
diff --git a/tests/repository.test.ts b/tests/repository.test.ts
index 095fe56..9e05777 100644
--- a/tests/repository.test.ts
+++ b/tests/repository.test.ts
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { tmpdir } from "node:os";
import type { DatabaseSync } from "node:sqlite";
import { createEmptyDraft, createEmptyItem, type SaveReceiptRequest } from "../shared/receipt.js";
+import { applyCurrencyPreference, confirmCurrency, UNKNOWN_CURRENCY } from "../shared/currency.js";
import { openDatabase } from "../server/database.js";
import { createBackupPayload, restoreBackupPayload } from "../server/backup.js";
import {
@@ -11,7 +12,10 @@ import {
getReceipt,
listReceipts,
ReceiptConflictError,
- saveReceipt
+ saveReceipt,
+ updateReceipt,
+ changeReceiptDeleted,
+ storePendingExtraction
} from "../server/repository.js";
let directory: string;
@@ -19,7 +23,7 @@ let databasePath: string;
let db: DatabaseSync;
function validRequest(): SaveReceiptRequest {
- const draft = createEmptyDraft();
+ const draft = createEmptyDraft("CAD");
const item = createEmptyItem(1);
item.normalizedName = "Large Eggs";
item.rawName = "EGGS LG 12";
@@ -58,6 +62,100 @@ afterEach(() => {
});
describe("transactional receipt persistence", () => {
+ it("updates receipt and item CRUD in place while preserving original evidence", () => {
+ const request = validRequest();
+ const extractionId = crypto.randomUUID();
+ storePendingExtraction(db, { id: extractionId, provider: "chatgpt-paste", model: null, status: "completed", rawResult: { merchantName: "Walmart", totalMinor: 499 }, uncertaintyFields: [], validationErrors: [] });
+ request.extractionId = extractionId;
+ const id = saveReceipt(db, request);
+ const original = getReceipt(db, id)!;
+ const added = createEmptyItem(2);
+ added.normalizedName = "Milk";
+ added.lineTotalMinor = 100;
+ const draft = { ...original.draft, merchantName: "Corrected shop", totalMinor: 599, items: [{ ...original.draft.items[0], category: "Food" }, added] };
+ const update = { clientMutationId: crypto.randomUUID(), expectedRevision: 1, status: "confirmed" as const, draft };
+ expect(updateReceipt(db, id, update)).toBe(id);
+ expect(updateReceipt(db, id, update)).toBe(id);
+ const changed = getReceipt(db, id)!;
+ expect(changed).toMatchObject({ revision: 2, scannedAt: original.scannedAt, confirmedAt: original.confirmedAt, attachment: original.attachment });
+ expect(changed.draft.items).toHaveLength(2);
+ expect(changed.extractions).toEqual(original.extractions);
+ expect(changed.auditEvents.at(-1)?.oldValue).toEqual(original.draft);
+ expect(listReceipts(db)).toHaveLength(1);
+ expect(listReceipts(db, "Milk")).toHaveLength(1);
+ const remove = { ...update, clientMutationId: crypto.randomUUID(), expectedRevision: 2, draft: { ...draft, totalMinor: 100, items: [added] } };
+ updateReceipt(db, id, remove);
+ db.close(); db = openDatabase(databasePath);
+ expect(getReceipt(db, id)?.draft.items.map((item) => item.id)).toEqual([added.id]);
+ expect(listReceipts(db, "Eggs")).toHaveLength(0);
+ });
+
+ it("rejects stale or unbalanced updates and rolls back child conflicts", () => {
+ const id = saveReceipt(db, validRequest());
+ const original = getReceipt(db, id)!;
+ const input = { clientMutationId: crypto.randomUUID(), expectedRevision: 1, status: "confirmed" as const, draft: { ...original.draft, totalMinor: 500 } };
+ expect(() => updateReceipt(db, id, input)).toThrow("ausgeglichener");
+ expect(getReceipt(db, id)?.revision).toBe(1);
+ const otherId = saveReceipt(db, validRequest());
+ const collidingItem = getReceipt(db, otherId)!.draft.items[0];
+ expect(() => updateReceipt(db, id, { ...input, draft: { ...original.draft, merchantName: "Must roll back", items: [collidingItem] } })).toThrow();
+ expect(getReceipt(db, id)?.draft).toEqual(original.draft);
+ const good = { ...input, draft: original.draft };
+ updateReceipt(db, id, good);
+ expect(() => updateReceipt(db, id, { ...good, clientMutationId: crypto.randomUUID() })).toThrow("erneut");
+ expect(() => updateReceipt(db, otherId, { ...good, expectedRevision: 1 })).toThrow("anderen Vorgang");
+ const uncertain = { ...original.draft, currency: UNKNOWN_CURRENCY };
+ expect(() => updateReceipt(db, id, { ...good, clientMutationId: crypto.randomUUID(), expectedRevision: 2, draft: uncertain })).toThrow("Währung");
+ updateReceipt(db, id, { ...good, clientMutationId: crypto.randomUUID(), expectedRevision: 2, status: "draft", draft: uncertain });
+ expect(getReceipt(db, id)).toMatchObject({ revision: 3, status: "draft", validationState: "incomplete", confirmedAt: null });
+ });
+
+ it("soft-deletes and restores receipts idempotently, retaining items and shared images", () => {
+ const id = saveReceipt(db, validRequest());
+ const second = saveReceipt(db, validRequest());
+ const original = getReceipt(db, id)!;
+ const mutation = { clientMutationId: crypto.randomUUID(), expectedRevision: 1 };
+ changeReceiptDeleted(db, id, mutation, true);
+ changeReceiptDeleted(db, id, mutation, true);
+ expect(getReceipt(db, id)).toMatchObject({ revision: 2, draft: original.draft, attachment: original.attachment });
+ expect(listReceipts(db).map((row) => row.id)).toEqual([second]);
+ expect(listReceipts(db, "", true).map((row) => row.id)).toEqual([id]);
+ expect(findDuplicateByHash(db, "a".repeat(64))).toBe(second);
+ expect(() => updateReceipt(db, id, { clientMutationId: crypto.randomUUID(), expectedRevision: 2, status: "confirmed", draft: original.draft })).toThrow("gelöscht");
+ expect(() => changeReceiptDeleted(db, id, { clientMutationId: crypto.randomUUID(), expectedRevision: 1 }, false)).toThrow("erneut");
+ const restore = { clientMutationId: crypto.randomUUID(), expectedRevision: 2 };
+ changeReceiptDeleted(db, id, restore, false);
+ changeReceiptDeleted(db, id, restore, false);
+ expect(getReceipt(db, id)).toMatchObject({ revision: 3, deletedAt: null, draft: original.draft });
+ expect(listReceipts(db)).toHaveLength(2);
+ expect(listReceipts(db, "", true)).toHaveLength(0);
+ });
+
+ it("keeps missing and suggested currencies in drafts until reviewed", () => {
+ const request = validRequest();
+ request.draft.currency = UNKNOWN_CURRENCY;
+ expect(() => saveReceipt(db, request)).toThrow("Währung");
+ request.status = "draft";
+ const missingId = saveReceipt(db, request);
+ expect(getReceipt(db, missingId)).toMatchObject({ validationState: "incomplete", draft: { currency: UNKNOWN_CURRENCY } });
+
+ request.clientMutationId = crypto.randomUUID();
+ request.draft = applyCurrencyPreference(request.draft, "EUR");
+ request.draft.items = request.draft.items.map((item) => ({ ...item, id: crypto.randomUUID() }));
+ const suggestedId = saveReceipt(db, request);
+ db.close();
+ db = openDatabase(databasePath);
+ const stored = getReceipt(db, suggestedId)!;
+ expect(stored.draft.fieldSources.currency).toBe("uncertain");
+ expect(stored.draft.uncertaintyFields).toContain("currency");
+ request.clientMutationId = crypto.randomUUID();
+ request.status = "confirmed";
+ request.draft = stored.draft;
+ expect(() => saveReceipt(db, request)).toThrow("Währung");
+ request.draft = confirmCurrency(request.draft, "EUR");
+ request.draft.items = request.draft.items.map((item) => ({ ...item, id: crypto.randomUUID() }));
+ expect(getReceipt(db, saveReceipt(db, request))?.draft.currency).toBe("EUR");
+ });
it("stores and reopens every item plus the original image reference", () => {
const request = validRequest();
const id = saveReceipt(db, request);
diff --git a/tests/server-migration.test.ts b/tests/server-migration.test.ts
new file mode 100644
index 0000000..a327b9b
--- /dev/null
+++ b/tests/server-migration.test.ts
@@ -0,0 +1,25 @@
+import { DatabaseSync } from "node:sqlite";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { describe, expect, it } from "vitest";
+import { openDatabase } from "../server/database.js";
+
+describe("server CRUD migration", () => {
+ it("upgrades an existing database once without dropping stored receipts", () => {
+ const directory = mkdtempSync(join(tmpdir(), "itemly-migration-test-"));
+ const path = join(directory, "receipts.sqlite");
+ let db = new DatabaseSync(path);
+ try {
+ db.exec(readFileSync(new URL("../server/migrations/001_initial.sql", import.meta.url), "utf8"));
+ db.exec("CREATE TABLE _migrations(name TEXT PRIMARY KEY, applied_at TEXT NOT NULL);");
+ db.prepare("INSERT INTO _migrations VALUES (?, ?)").run("001_initial.sql", "2026-09-26");
+ db.prepare(`INSERT INTO receipts(id, client_mutation_id, currency, position_count, status, validation_state, scanned_at, schema_version)
+ VALUES ('legacy', 'legacy-mutation', 'CAD', 0, 'draft', 'incomplete', '2026-09-26', '1.0')`).run();
+ db.close(); db = openDatabase(path);
+ expect(db.prepare("SELECT id, currency, revision, deleted_at FROM receipts").get()).toMatchObject({ id: "legacy", currency: "CAD", revision: 1, deleted_at: null });
+ db.close(); db = openDatabase(path);
+ expect(db.prepare("SELECT COUNT(*) AS count FROM _migrations").get()).toMatchObject({ count: 2 });
+ } finally { db.close(); rmSync(directory, { recursive: true, force: true }); }
+ });
+});
diff --git a/tsconfig.server.json b/tsconfig.server.json
index 9621390..3b6c8c1 100644
--- a/tsconfig.server.json
+++ b/tsconfig.server.json
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES2023",
- "lib": ["ES2023"],
+ "lib": ["ES2023", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
@@ -10,7 +10,7 @@
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"noEmit": true,
- "types": ["node", "vitest/globals"]
+ "types": ["node", "vitest/globals", "vite/client"]
},
"include": ["server", "shared", "tests", "vite.config.ts"]
}