feat: add iOS support and harden receipt scanning

This commit is contained in:
Timo
2026-08-20 23:48:26 +02:00
parent f5e06c32b0
commit cf1b799e1b
107 changed files with 11755 additions and 122 deletions

657
docs/ios-app-plan.html Normal file
View File

@@ -0,0 +1,657 @@
<title>ScanReceipts für iOS</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Condensed:wght@500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap">
<style>
:root {
--bg: #F4F5F7;
--surface: #FFFFFF;
--surface-2: #EDEFF3;
--border: #DBE0E8;
--ink: #12151C;
--ink-2: #4B5566;
--ink-3: #77828F;
--accent: #1E4FD8;
--accent-ink: #12327A;
--good: #157A52;
--good-bg: #E4F5EC;
--warn: #A15E05;
--warn-bg: #FBF0DC;
--critical: #B23434;
--critical-bg: #FBE7E5;
--font-display: "IBM Plex Sans Condensed", "Arial Narrow", sans-serif;
--font-body: "IBM Plex Serif", Georgia, "Times New Roman", serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0F1319;
--surface: #171C24;
--surface-2: #1E242E;
--border: #2B3340;
--ink: #E9ECF1;
--ink-2: #AEB7C4;
--ink-3: #78828F;
--accent: #6E93FF;
--accent-ink: #9DB4FF;
--good: #4FBF8E;
--good-bg: #12281F;
--warn: #E0A542;
--warn-bg: #2E2410;
--critical: #E5776C;
--critical-bg: #2E1614;
}
}
:root[data-theme="dark"] {
--bg: #0F1319;
--surface: #171C24;
--surface-2: #1E242E;
--border: #2B3340;
--ink: #E9ECF1;
--ink-2: #AEB7C4;
--ink-3: #78828F;
--accent: #6E93FF;
--accent-ink: #9DB4FF;
--good: #4FBF8E;
--good-bg: #12281F;
--warn: #E0A542;
--warn-bg: #2E2410;
--critical: #E5776C;
--critical-bg: #2E1614;
}
* { box-sizing: border-box; }
body {
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.65;
margin: 0;
padding: 0 20px 96px;
}
.shell {
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: 220px minmax(0, 780px);
gap: 56px;
padding-top: 64px;
align-items: start;
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; gap: 24px; padding-top: 40px; }
nav.toc { position: static; order: 2; }
}
header.masthead {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 8px;
padding-bottom: 28px;
border-bottom: 2px solid var(--ink);
}
.eyebrow {
font-family: var(--font-mono);
font-size: 12.5px;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent-ink);
}
h1.title {
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(2rem, 4.2vw, 2.9rem);
line-height: 1.05;
margin: 0;
text-wrap: balance;
letter-spacing: -0.01em;
}
.dek {
font-family: var(--font-body);
font-style: italic;
color: var(--ink-2);
font-size: 1.08rem;
max-width: 62ch;
margin: 0;
}
.meta-row {
display: flex;
gap: 20px;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--ink-3);
margin-top: 6px;
}
nav.toc {
position: sticky;
top: 40px;
align-self: start;
font-family: var(--font-display);
font-size: 13.5px;
}
nav.toc ol {
list-style: none;
margin: 0;
padding: 0;
border-left: 1px solid var(--border);
}
nav.toc li { margin: 0; }
nav.toc a {
display: block;
padding: 6px 0 6px 14px;
color: var(--ink-2);
text-decoration: none;
border-left: 2px solid transparent;
margin-left: -1px;
}
nav.toc a:hover { color: var(--accent-ink); border-left-color: var(--accent); }
nav.toc .toc-title {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
margin-bottom: 10px;
}
main { min-width: 0; }
section { margin-top: 56px; }
section:first-of-type { margin-top: 8px; }
h2 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.5rem;
display: flex;
align-items: baseline;
gap: 12px;
margin: 0 0 6px;
text-wrap: balance;
scroll-margin-top: 24px;
}
h2 .num {
font-family: var(--font-mono);
font-size: 0.95rem;
color: var(--accent-ink);
font-weight: 500;
}
.section-intro {
color: var(--ink-2);
font-size: 0.98rem;
max-width: 66ch;
margin: 0 0 20px;
}
h3 {
font-family: var(--font-display);
font-weight: 600;
font-size: 1.08rem;
margin: 28px 0 10px;
}
p { margin: 0 0 14px; max-width: 68ch; }
ul, ol { margin: 0 0 16px; padding-left: 22px; max-width: 66ch; }
li { margin-bottom: 6px; }
strong { font-weight: 600; color: var(--ink); }
a { color: var(--accent-ink); }
code {
font-family: var(--font-mono);
font-size: 0.87em;
background: var(--surface-2);
padding: 0.1em 0.4em;
border-radius: 3px;
color: var(--ink);
}
.callout {
border: 1px solid var(--border);
background: var(--surface);
border-left: 3px solid var(--accent);
padding: 16px 18px;
border-radius: 2px;
margin: 18px 0;
font-size: 0.96rem;
}
.callout.good { border-left-color: var(--good); }
.callout.warn { border-left-color: var(--warn); }
.callout.critical { border-left-color: var(--critical); background: var(--critical-bg); }
.callout .label {
display: block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
color: var(--ink-3);
}
.callout.critical .label { color: var(--critical); }
.callout.good .label { color: var(--good); }
.callout.warn .label { color: var(--warn); }
.callout p:last-child { margin-bottom: 0; }
.table-wrap { overflow-x: auto; margin: 18px 0; border: 1px solid var(--border); border-radius: 3px; }
table { border-collapse: collapse; width: 100%; min-width: 560px; font-size: 0.92rem; background: var(--surface); }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); vertical-align: top; }
thead th {
font-family: var(--font-display);
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--ink-2);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
tbody tr:last-child td { border-bottom: none; }
td.mono, th.mono { font-family: var(--font-mono); font-size: 0.85em; }
td.num, th.num { font-variant-numeric: tabular-nums; }
.pill {
display: inline-block;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.03em;
padding: 2px 8px;
border-radius: 999px;
white-space: nowrap;
}
.pill.good { background: var(--good-bg); color: var(--good); }
.pill.warn { background: var(--warn-bg); color: var(--warn); }
.pill.critical { background: var(--critical-bg); color: var(--critical); }
.pill.neutral { background: var(--surface-2); color: var(--ink-2); }
.phase {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 4px;
padding: 18px 20px;
margin: 16px 0;
}
.phase-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.phase-head h4 {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.05rem;
margin: 0;
}
.phase-effort {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ink-3);
}
.phase ul { margin-bottom: 0; }
hr.rule {
border: none;
border-top: 1px solid var(--border);
margin: 40px 0;
}
.kicker-list {
display: grid;
gap: 10px;
margin: 18px 0;
}
.kicker-list .item {
display: grid;
grid-template-columns: 22px 1fr;
gap: 10px;
align-items: baseline;
}
.kicker-list .item .glyph {
font-family: var(--font-mono);
color: var(--accent-ink);
font-size: 0.9rem;
}
footer.doc-footer {
grid-column: 1 / -1;
margin-top: 64px;
padding-top: 24px;
border-top: 1px solid var(--border);
color: var(--ink-3);
font-family: var(--font-mono);
font-size: 12px;
}
@media (prefers-reduced-motion: no-preference) {
a { transition: color 0.15s ease, border-color 0.15s ease; }
}
</style>
<div class="shell">
<header class="masthead">
<span class="eyebrow">Architektur- &amp; Umsetzungsplan · scan-receipts.app</span>
<h1 class="title">ScanReceipts für iOS</h1>
<p class="dek">Wie aus der bestehenden Next.js-Webanwendung eine native iOS-App wird — mit demselben Postgres-Backend, demselben Pro-Account, ohne die Web-Plattform anzufassen.</p>
<div class="meta-row">
<span>Stand 2026-08-20</span>
<span>Basis: aktueller Code-Stand von scan-receipts.app</span>
<span>Für: App-Store-Launch (iOS)</span>
</div>
</header>
<nav class="toc">
<div class="toc-title">Inhalt</div>
<ol>
<li><a href="#bestand">1 — Bestandsaufnahme</a></li>
<li><a href="#kann-nicht">2 — Kann / kann nicht / soll</a></li>
<li><a href="#technologie">3 — Technologie-Entscheidung</a></li>
<li><a href="#apple-iap">4 — Der harte Punkt: Apple IAP</a></li>
<li><a href="#architektur">5 — Ein Backend, zwei Clients</a></li>
<li><a href="#parität">6 — Feature-Parität</a></li>
<li><a href="#phasen">7 — Phasenplan</a></li>
<li><a href="#recht">8 — Rechtliches &amp; App-Store-Freigabe</a></li>
<li><a href="#risiken">9 — Offene Entscheidungen</a></li>
<li><a href="#naechste">10 — Nächster Schritt</a></li>
</ol>
</nav>
<main>
<section id="bestand">
<h2><span class="num">01</span> Bestandsaufnahme</h2>
<p class="section-intro">Das ist heute schon da — und es ist mehr, als für eine iOS-App nötig wäre. Der Plan unten baut bewusst darauf auf, statt etwas Neues danebenzustellen.</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>Bereich</th><th>Ist-Zustand</th></tr>
</thead>
<tbody>
<tr><td>Frontend</td><td>Next.js 15 (App Router), React 19, TypeScript, Tailwind — als Website ausgeliefert, kein Mobile-Client.</td></tr>
<tr><td>Backend</td><td>Dieselbe Next.js-App: REST-artige JSON-Endpunkte unter <code>/api/*</code> für Auth, Scan, Receipts, Projects, Export, Billing.</td></tr>
<tr><td>Datenbank</td><td>PostgreSQL via Drizzle ORM. Tabellen für <code>users</code>, <code>sessions</code>, <code>receipts</code>, <code>projects</code>, <code>licenses</code>, <code>security_events</code> existieren bereits vollständig.</td></tr>
<tr><td>Auth</td><td>Selbstgebaut: E-Mail/Passwort + Google OAuth. Server-seitige Sessions (Token in httpOnly-Cookie, nur Hash in der DB) plus CSRF-Double-Submit-Cookie für Browser-Requests.</td></tr>
<tr><td>Abrechnung</td><td>Stripe Checkout — Wochen-Pass (4,99&nbsp;€, Abo), Jahres-Pass (39,99&nbsp;€, Abo), Lifetime (59,99&nbsp;€, Einmalzahlung). Webhook setzt <code>users.plan</code> / <code>expiresAt</code>.</td></tr>
<tr><td>Beleg-Speicherung</td><td>Zwei Modi: nicht angemeldet → lokal im Browser (IndexedDB, „Guest Bucket“). Angemeldet → serverseitig in Postgres, pro <code>userId</code> isoliert. <strong>Das ist der Teil, der die iOS-App trägt.</strong></td></tr>
<tr><td>KI-Extraktion</td><td>Serverseitig via Vercel AI SDK (OpenAI / Google) — Bild rein, strukturierte Beleg-Daten raus. Läuft schon rein serverseitig, ein iOS-Client müsste nur ein Bild hochladen.</td></tr>
<tr><td>Export</td><td>Dual-Sheet XLSX (ExcelJS), DATEV-CSV, PDF — serverseitig generiert.</td></tr>
<tr><td>Deployment</td><td>Docker Compose (App + Postgres), nginx davor, eigenes Least-Privilege-DB-Rollenmodell, Security-Hardening bereits durchgeführt.</td></tr>
<tr><td>Rechtstexte</td><td>Impressum/Datenschutz/AGB sind aktuell US-Templates ohne DSGVO-Inhalt — <strong>bekannte Lücke</strong>, relevant auch für den App-Store-Review (siehe Abschnitt 8).</td></tr>
</tbody>
</table>
</div>
<div class="callout good">
<span class="label">Wichtigster Befund</span>
<p>Es gibt schon eine vollständige, mandantenfähige REST-API mit Session-Auth, Stripe-Billing und Postgres-Persistenz pro Nutzer. Eine iOS-App muss <strong>kein neues Backend</strong> bekommen — sie wird ein zweiter Client für das bestehende. Der Datenbank-Teil der Aufgabe („gleicher Pro-Account auf Web und App“) ist damit strukturell schon gelöst, sobald die App sich gegen dieselbe API authentifiziert.</p>
</div>
</section>
<section id="kann-nicht">
<h2><span class="num">02</span> Kann / kann nicht / soll / soll nicht</h2>
<p class="section-intro">Kurz eingeordnet, damit der iOS-Scope nicht zufällig größer wird als die Web-App selbst.</p>
<h3>Kann heute (Web)</h3>
<ul>
<li>Belege per Foto/Scan/PDF hochladen, KI-Extraktion (Händler, Betrag, MwSt., Position, Kategorie).</li>
<li>Beleg-Review mit Bounding-Box-Abgleich, manuelle Korrektur, Line-Items editieren.</li>
<li>Ordner/Projekte (Pro), Filter, Volltextsuche, Bulk-Aktionen.</li>
<li>Export als XLSX (Dual-Sheet), DATEV-CSV, PDF.</li>
<li>Account, Login, Passwort-Reset, Google-Login, Pro-Abo via Stripe.</li>
<li>Admin-Dashboard (intern, nicht kundenrelevant).</li>
</ul>
<h3>Kann nicht / bewusst nicht</h3>
<ul>
<li>Keine Offline-Erst-Nutzung für angemeldete Nutzer — Belege eingeloggter Accounts leben serverseitig, nicht lokal.</li>
<li>Keine automatisierte Buchhaltungs-Anbindung (DATEV-Direktexport ist eine Datei, kein API-Sync).</li>
<li>Keine Team-/Mehrbenutzer-Konten — ein Account = ein Nutzer.</li>
</ul>
<h3>Soll die iOS-App</h3>
<ul>
<li>Dieselben Belege, denselben Account, denselben Pro-Status zeigen wie die Web-App — <em>synchron</em>, nicht als Kopie.</li>
<li>Die native Stärke von iOS nutzen, die die Website nicht hat: Kamera-Dokumentenscanner (VisionKit) statt Datei-Upload, Share-Sheet-Integration („Beleg direkt aus Mail/Fotos an ScanReceipts schicken“), Face ID/Touch ID zum App-Öffnen, Push-Benachrichtigung bei fertiger Extraktion.</li>
<li>Denselben Funktionsumfang wie das Web-Dashboard abbilden: Übersicht, Review, Export, Einstellungen, Abo-Verwaltung.</li>
</ul>
<h3>Soll die iOS-App nicht</h3>
<ul>
<li>Kein eigenständiges Produkt mit eigener Feature-Roadmap — sie folgt der Web-App, nicht umgekehrt.</li>
<li>Kein zweites Backend, keine zweite Datenbank, keine Datenhaltung, die aus dem Sync herausfällt.</li>
</ul>
</section>
<section id="technologie">
<h2><span class="num">03</span> Technologie-Entscheidung</h2>
<p class="section-intro">Drei realistische Wege für den Client. Die Wahl bestimmt Aufwand, App-Store-Wahrnehmung und wie viel vom bestehenden React-Code wiederverwendbar ist.</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>Ansatz</th><th>Aufwand</th><th>iOS-Gefühl</th><th>Code-Wiederverwendung</th><th>Kamera-Scan-Qualität</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Nativ, Swift/SwiftUI</strong></td>
<td class="pill neutral">Hoch (neuer Code)</td>
<td class="pill good">Bestmöglich</td>
<td>Keine (nur die API-Verträge)</td>
<td class="pill good">VisionKit nativ, beste Qualität</td>
</tr>
<tr>
<td><strong>React Native / Expo</strong></td>
<td class="pill neutral">Mittel</td>
<td class="pill warn">Gut, mit Feinschliff</td>
<td>Logik/Hooks ja, UI-Komponenten nein (kein DOM)</td>
<td class="pill warn">Über Community-Module, weniger nativ</td>
</tr>
<tr>
<td><strong>Capacitor (Web-App im Wrapper)</strong></td>
<td class="pill good">Niedrig</td>
<td class="pill critical">Schwach — fühlt sich wie Website an</td>
<td>Fast alles</td>
<td class="pill critical">Nur über Plugin, Web-Kamera-API als Fallback</td>
</tr>
</tbody>
</table>
</div>
<div class="callout">
<span class="label">Empfehlung</span>
<p><strong>Natives SwiftUI</strong> für den Client, mit dem bestehenden Next.js-Backend als reiner API-Lieferant. Begründung: ScanReceipts lebt vom Dokumentenscan — genau da ist der Unterschied zwischen einer nativen VisionKit-Kamera und einer Web-Kamera-API am größten spürbar. Zusätzlich bewertet Apples Review nativ gebaute Apps im Zweifel wohlwollender, und ein Finanz-/Beleg-Produkt sollte Face-ID-Schutz, Keychain-Speicherung und ordentliches Offline-Verhalten haben — Dinge, die in SwiftUI Bordmittel sind und in einem Wrapper immer ein Stück Umweg bleiben. Der Mehraufwand gegenüber Capacitor zahlt sich hier aus, weil das Produkt kein einfaches Formular ist, sondern seine Existenzberechtigung aus Kamera + KI-Review zieht.</p>
</div>
<p>Realistische Alternative, falls Zeit/Budget der limitierende Faktor ist: <strong>Capacitor</strong> als schneller erster Wurf, um früh im App Store zu stehen, mit dem Plan, die Kamera- und Review-Screens später nativ nachzuziehen. Das ist ein legitimer Kompromiss — sollte aber als bewusste Zwischenstufe behandelt werden, nicht als Endzustand, sonst bleibt die App dauerhaft bei „fühlt sich wie eine Website an“ stehen.</p>
</section>
<section id="apple-iap">
<h2><span class="num">04</span> Der harte Punkt: Apple In-App-Purchase</h2>
<p class="section-intro">Das ist der Teil des Plans, der die Web-Logik nicht einfach übernehmen kann — und der beim App-Store-Review scheitert, wenn er übergangen wird.</p>
<div class="callout critical">
<span class="label">Apple Guideline 3.1.1 — Pflicht, kein Nice-to-have</span>
<p>Digitale Inhalte/Funktionen, die <em>innerhalb</em> der iOS-App freigeschaltet werden — hier: der Pro-Status, der mehr Scans, Ordner und Export freischaltet — müssen über <strong>Apple In-App Purchase (StoreKit)</strong> verkauft werden, sobald der Kauf aus der App heraus angestoßen wird. Ein Stripe-Checkout-Link, der in der App geöffnet wird, führt zur Ablehnung im Review. Die „Reader-App“-Ausnahme (die z. B. Spotify/Netflix nutzen, um extern zu verkaufen) greift nur für Apps, deren Kerninhalt außerhalb der App erworben und in der App nur konsumiert wird — ein Belegscanner mit Nutzungslimits erfüllt dieses Kriterium nicht.</p>
</div>
<h3>Was das konkret bedeutet</h3>
<ul>
<li>Für den iOS-Kauf braucht es <strong>StoreKit 2</strong> mit eigenen Produkten im App Store Connect — die bestehenden Stripe-Preise (Wochen-Pass, Jahres-Pass, Lifetime) müssen als Apple-Abos/In-App-Kauf gespiegelt werden. Preise dürfen unterschiedlich sein (Apple hat eigene Preisstufen und behält bis zu 30&nbsp;% / 15&nbsp;% im Small-Business-Programm ein) — das ist im Pricing einzuplanen, nicht kosmetisch.</li>
<li>Der Server muss iOS-Käufe genauso erkennen wie Stripe-Käufe: Apple schickt serverseitige <strong>App Store Server Notifications V2</strong> (das iOS-Äquivalent zum Stripe-Webhook). Ein neuer Endpunkt <code>/api/webhooks/apple</code> validiert die JWS-signierte Notification und setzt <code>users.plan</code>/<code>expiresAt</code> — dieselbe Funktion (<code>isProActive</code>), die heute schon für Stripe existiert, bedient dann beide Quellen.</li>
<li>Ein Nutzer, der auf der Website mit Stripe zahlt, muss in der App sofort als Pro erkannt werden (und umgekehrt) — das ist automatisch der Fall, weil beide Wege am Ende nur <code>users.plan</code> in derselben Zeile setzen. Es gibt keinen Bedarf, zwei Kaufwege gegeneinander zu synchronisieren; sie schreiben ins selbe Feld.</li>
<li>Empfehlung fürs Onboarding in der App: Bereits-Web-Kunden loggen sich einfach mit ihrem bestehenden Account ein und sind sofort Pro — kein Kauf in der App nötig. Der In-App-Kauf ist nur für Leute relevant, die <em>zum ersten Mal in der App</em> kaufen wollen.</li>
<li>Ein optionales Spalten-Add-on im Schema (<code>purchase_source: 'stripe' | 'apple'</code>) hilft später bei Support/Kündigung, ist aber kein Launch-Blocker.</li>
</ul>
<h3>Zweiter Pflichtpunkt: Sign in with Apple</h3>
<p>Weil die App Google-Login anbietet (bzw. anbieten würde), verlangt <strong>Guideline&nbsp;4.8</strong>, dass parallel auch „Sign in with Apple“ angeboten wird. E-Mail/Passwort-Login allein reicht als Ausweg nicht, sobald ein Drittanbieter-Login existiert. Technisch ist das ein weiterer OAuth-ähnlicher Provider neben dem bestehenden <code>oauth_accounts</code>-Mechanismus (aktuell nur <code>provider: 'google'</code>) — Aufwand ist überschaubar, weil die Tabelle dafür schon vorgesehen ist.</p>
</section>
<section id="architektur">
<h2><span class="num">05</span> Ein Backend, zwei Clients</h2>
<p class="section-intro">Was am Next.js-Backend geändert werden muss, damit es einen nativen App-Client genauso sauber bedient wie den Browser — ohne die Web-Seite zu verändern.</p>
<h3>5.1 Auth: Bearer-Token statt Cookie für die App</h3>
<p>Die Web-App nutzt einen httpOnly-Session-Cookie plus ein CSRF-Double-Submit-Cookie (<code>sr_session</code> / <code>sr_csrf</code>) — beides ist ein Browser-Mechanismus und funktioniert in einer nativen App nicht (kein Cookie-Jar, kein DOM zum Auslesen des CSRF-Cookies). Für native Clients braucht es einen zweiten, parallelen Auth-Pfad, <strong>ohne</strong> den bestehenden zu verändern:</p>
<ul>
<li>Login/Signup-Endpunkte erkennen einen App-Client (z. B. Header <code>X-Client: ios</code>) und geben das Session-Token zusätzlich im JSON-Body zurück statt nur als Set-Cookie.</li>
<li>Die App speichert das Token in der <strong>iOS Keychain</strong> (nicht UserDefaults) und schickt es als <code>Authorization: Bearer &lt;token&gt;</code>.</li>
<li><code>getCurrentUser()</code> in <code>src/lib/auth/session.ts</code> liest zusätzlich den Authorization-Header, nicht nur das Cookie — dieselbe <code>sessions</code>-Tabelle, derselbe Hash-Vergleich, nur eine zweite Quelle für den Token.</li>
<li>CSRF-Pflicht (<code>requireCsrf</code>) gilt nur für Cookie-authentifizierte Requests — ein Request mit gültigem Bearer-Token ist per Definition kein Cross-Site-Request eines fremden Browsers und braucht den Double-Submit-Schutz nicht. Das ist eine kleine, gut abgegrenzte Änderung an einer einzigen Funktion, keine Umstrukturierung.</li>
</ul>
<div class="callout good">
<span class="label">Warum das reicht</span>
<p>CORS und die Origin-Prüfung in <code>isAllowedOrigin</code> betreffen ausschließlich Browser — eine native App sendet keinen <code>Origin</code>-Header und ist von dieser Prüfung ohnehin nicht betroffen (der Code behandelt „kein Origin-Header“ schon heute als Server-zu-Server-Fall). Es muss an der Web-Sicherheit nichts gelockert werden, damit die App funktioniert.</p>
</div>
<h3>5.2 Scan-Endpunkt bleibt, Upload-Quelle ändert sich</h3>
<p><code>POST /api/scan</code> nimmt heute schon <code>multipart/form-data</code> mit einer Bild-/PDF-Datei entgegen und braucht eine verifizierte Session. Die App liefert statt einer Browser-<code>File</code> ein per VisionKit gescanntes Bild (JPEG) — der Endpunkt selbst ändert sich nicht, nur wer ihn aufruft.</p>
<h3>5.3 Push-Benachrichtigungen (optional, sinnvoll)</h3>
<p>Die KI-Extraktion läuft serverseitig und dauert ein paar Sekunden. Für die App lohnt sich ein <code>device_tokens</code>-Tabelle (userId → APNs-Token) plus ein Push nach fertiger Extraktion — kein Muss für Version 1, aber eine kleine, klar abgegrenzte Erweiterung, kein Umbau.</p>
<h3>5.4 Was sich <strong>nicht</strong> ändert</h3>
<ul>
<li>Datenmodell (<code>users</code>, <code>receipts</code>, <code>projects</code>, <code>line_items</code>) — 1:1 wiederverwendbar, kein neues Schema.</li>
<li><code>/api/receipts</code>, <code>/api/projects</code>, <code>/api/export/*</code> — unverändert nutzbar, sobald die App authentifiziert ist.</li>
<li>Die KI-Extraktionslogik, die Stripe-Web-Zahlung, das Admin-Dashboard.</li>
</ul>
</section>
<section id="parität">
<h2><span class="num">06</span> Feature-Parität: Web → iOS</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>Web-Feature</th><th>iOS-Umsetzung</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td>Datei-Upload / Dropzone</td><td>VisionKit-Dokumentenscanner (<code>VNDocumentCameraViewController</code>) + Fotobibliothek + Dateien-App</td><td class="pill neutral">Neu (nativ)</td></tr>
<tr><td>Dual-Pane Review-Modal</td><td>Eigener SwiftUI-Screen: Bild oben/seitlich, Felder darunter — Layout an schmalen Screen angepasst statt 50/50-Split</td><td class="pill neutral">Neu (nativ)</td></tr>
<tr><td>Line-Items-Editor</td><td>Native Liste mit Swipe-to-Delete, Inline-Edit</td><td class="pill neutral">Neu (nativ)</td></tr>
<tr><td>LiveTable / Belegübersicht</td><td>Native Liste (<code>List</code>/<code>LazyVStack</code>) mit denselben Statusbadges</td><td class="pill neutral">Neu (nativ)</td></tr>
<tr><td>Filter-Chips, Suche, Bulk-Aktionen</td><td>Gleiche Logik, native Controls</td><td class="pill neutral">Neu (nativ)</td></tr>
<tr><td>Export XLSX/CSV/PDF</td><td>Ruft denselben <code>/api/export/*</code>-Endpunkt auf, zeigt iOS-Share-Sheet zum Speichern/Versenden</td><td class="pill good">API wiederverwendbar</td></tr>
<tr><td>Ordner/Projekte (Pro)</td><td>Native Ordneransicht über <code>/api/projects</code></td><td class="pill good">API wiederverwendbar</td></tr>
<tr><td>Login / Signup / Passwort-Reset</td><td>Native Formulare gegen bestehende <code>/api/auth/*</code>-Endpunkte, plus Sign in with Apple</td><td class="pill warn">API + Apple-Login nötig</td></tr>
<tr><td>Pro-Abo abschließen</td><td>StoreKit 2 statt Stripe Checkout — siehe Abschnitt 4</td><td class="pill critical">Muss neu gebaut werden</td></tr>
<tr><td>Abo verwalten/kündigen</td><td>Verweis auf iOS-Systemeinstellungen (Apple verwaltet In-App-Abos zentral) für App-Käufe; bestehender Web-Flow bleibt für Stripe-Käufe</td><td class="pill warn">Zwei Pfade, je nach Kaufquelle</td></tr>
<tr><td>Admin-Dashboard</td><td>Kein iOS-Äquivalent nötig — bleibt Web-only</td><td class="pill neutral">Out of scope</td></tr>
</tbody>
</table>
</div>
</section>
<section id="phasen">
<h2><span class="num">07</span> Phasenplan</h2>
<p class="section-intro">Sechs Phasen, jede für sich abnahmefähig. Aufwandsangaben sind grobe Orientierung für eine Einzelperson bzw. ein kleines Team, kein Fixpreis-Angebot.</p>
<div class="phase">
<div class="phase-head"><h4>Phase 0 — Vorbereitung</h4><span class="phase-effort">~1 Woche</span></div>
<ul>
<li>Apple Developer Program Account anlegen (99&nbsp;$/Jahr), Bundle-ID, App-Store-Connect-Eintrag.</li>
<li>Xcode-Projekt aufsetzen, SwiftUI-App-Grundgerüst, API-Client-Layer (URLSession + Codable, gegen die bestehenden JSON-Verträge).</li>
<li>Backend: Bearer-Token-Auth-Pfad (Abschnitt 5.1) implementieren und gegen die App testen — parallel zum bestehenden Cookie-Flow, ohne ihn zu verändern.</li>
</ul>
</div>
<div class="phase">
<div class="phase-head"><h4>Phase 1 — Auth &amp; Account</h4><span class="phase-effort">~11,5 Wochen</span></div>
<ul>
<li>Login, Signup, Passwort-Reset, E-Mail-Verifizierung (Deep-Link/Universal-Link zurück in die App).</li>
<li>Sign in with Apple (Pflicht wegen Google-Login, Abschnitt 4).</li>
<li>Face-ID/Touch-ID-Sperre für App-Öffnen (Nice-to-have, aber bei Finanzdaten naheliegend).</li>
</ul>
</div>
<div class="phase">
<div class="phase-head"><h4>Phase 2 — Scannen &amp; Review</h4><span class="phase-effort">~23 Wochen</span></div>
<ul>
<li>VisionKit-Dokumentenscanner, Upload an <code>/api/scan</code>, Fortschrittsanzeige.</li>
<li>Review-Screen: extrahierte Felder anzeigen/korrigieren, Line-Items editieren, speichern.</li>
<li>Belegübersicht (Liste), Status-Badges, Suche/Filter.</li>
</ul>
</div>
<div class="phase">
<div class="phase-head"><h4>Phase 3 — Pro-Kauf (StoreKit)</h4><span class="phase-effort">~1,52 Wochen</span></div>
<ul>
<li>Produkte in App Store Connect anlegen (Wochen-/Jahres-Abo, Lifetime als Non-Consumable).</li>
<li>StoreKit-2-Kaufabwicklung in der App.</li>
<li><code>/api/webhooks/apple</code> für App Store Server Notifications V2, verknüpft mit <code>isProActive</code>-Logik.</li>
<li>Paywall-Screen, Restore-Purchases-Flow.</li>
</ul>
</div>
<div class="phase">
<div class="phase-head"><h4>Phase 4 — Export &amp; Ordner</h4><span class="phase-effort">~1 Woche</span></div>
<ul>
<li>Export-Screen gegen bestehende <code>/api/export/*</code>-Endpunkte, iOS-Share-Sheet.</li>
<li>Ordner/Projekte-Verwaltung (Pro-Feature) gegen <code>/api/projects</code>.</li>
</ul>
</div>
<div class="phase">
<div class="phase-head"><h4>Phase 5 — App-Store-Freigabe</h4><span class="phase-effort">~12 Wochen (inkl. Review-Wartezeit)</span></div>
<ul>
<li>DSGVO-konforme Datenschutzerklärung + App Privacy „Nutrition Label“ in App Store Connect (siehe Abschnitt 8 — <strong>Blocker</strong>, unabhängig von der App selbst).</li>
<li>Screenshots, App-Store-Text, TestFlight-Beta mit echten Testern.</li>
<li>Review-Einreichung, typischerweise 13 Tage Wartezeit, ggf. Nachbesserung bei Rejection.</li>
</ul>
</div>
</section>
<section id="recht">
<h2><span class="num">08</span> Rechtliches &amp; App-Store-Freigabe</h2>
<p class="section-intro">Dinge, die unabhängig vom Code erledigt sein müssen, bevor Apple die App überhaupt annimmt.</p>
<div class="callout critical">
<span class="label">Bekannte Lücke, jetzt relevant</span>
<p>Die aktuellen Rechtstexte (Impressum, Datenschutz, AGB) sind US-Templates ohne DSGVO-Inhalt. Für die Website ist das ein Launch-Blocker; für den App-Store-Review ist es das <strong>ebenfalls</strong> — App Store Connect verlangt eine echte, erreichbare Datenschutz-URL, und deren Inhalt muss mit dem übereinstimmen, was im „App Privacy“-Fragebogen angegeben wird (welche Daten werden erhoben: E-Mail, Zahlungsdaten via Apple, Beleg-/Finanzdaten, Standort falls genutzt). Diese Arbeit sollte vor Phase&nbsp;5 laufen, nicht danach.</p>
</div>
<ul>
<li><strong>Apple Developer Program</strong> — 99&nbsp;$/Jahr, Einzelperson oder Organisation (D-U-N-S-Nummer bei Organisation, dauert erfahrungsgemäß am längsten — früh beantragen).</li>
<li><strong>App Privacy Nutrition Label</strong> — Katalog aller erhobenen Datentypen (Kontakt, Finanzdaten, Nutzungsdaten) je Verwendungszweck; muss zum tatsächlichen Datenfluss passen (Belegbilder + extrahierte Beträge sind „Finanzdaten“).</li>
<li><strong>Export-Compliance</strong> — Standard-HTTPS/TLS-Verschlüsselung ist deklarationspflichtig, aber unkritisch (Standardformular in App Store Connect).</li>
<li><strong>Kassenbon-/Steuerdaten aus Deutschland</strong> — keine App-Store-spezifische Anforderung, aber die DSGVO-Verarbeitung (Auftragsverarbeitungsvertrag mit dem Hosting, Löschkonzept) sollte ohnehin für die Website nachgezogen werden; die App teilt sich dasselbe Backend und damit dieselbe Rechtsgrundlage.</li>
<li><strong>Impressumspflicht</strong> — in der App selbst (nicht nur auf der Website verlinkt) empfehlenswert, da deutsches Recht (TMG/DDG) Diensteanbieter-Kennzeichnung auch für Apps nahelegt.</li>
</ul>
</section>
<section id="risiken">
<h2><span class="num">09</span> Offene Entscheidungen</h2>
<p class="section-intro">Punkte, die vor oder während Phase&nbsp;0 eine bewusste Entscheidung brauchen — keine davon blockiert den Start, aber alle beeinflussen den Zuschnitt.</p>
<div class="kicker-list">
<div class="item"><span class="glyph"></span><span><strong>Preisparität App vs. Web:</strong> Gleiche Preise trotz Apples Provision (30&nbsp;% / 15&nbsp;% im Small-Business-Programm) oder App-Preise leicht anheben, um die Marge zu halten? Viele Apps lösen das mit leicht höheren In-App-Preisen.</span></div>
<div class="item"><span class="glyph"></span><span><strong>Android parallel oder später?</strong> Der Plan hier ist iOS-spezifisch; eine spätere Android-Version bräuchte denselben Bearer-Token-Auth-Pfad plus Google-Play-Billing statt StoreKit — das Backend-Muster aus Abschnitt&nbsp;5 trägt beides.</span></div>
<div class="item"><span class="glyph"></span><span><strong>Offline-Fähigkeit in der App:</strong> Soll die App wie die Web-Version einen anonymen Lokal-Modus haben, oder ist Login von Anfang an Pflicht? Login-Pflicht vereinfacht Phase&nbsp;12 spürbar.</span></div>
<div class="item"><span class="glyph"></span><span><strong>React Native/Expo statt nativ:</strong> Falls Time-to-Market wichtiger ist als natives Gefühl, ist das eine valide Umentscheidung — würde vor allem Phase&nbsp;24 betreffen, Abschnitt&nbsp;4 und&nbsp;5 blieben unverändert gültig.</span></div>
</div>
</section>
<section id="naechste">
<h2><span class="num">10</span> Nächster Schritt</h2>
<p>Der kleinste sinnvolle erste Schritt, der noch keine App-Store-Kosten oder Xcode-Setup voraussetzt: den <strong>Bearer-Token-Auth-Pfad</strong> aus Abschnitt&nbsp;5.1 im bestehenden Next.js-Backend bauen und mit <code>curl</code>/Postman durchtesten. Das ist eine in sich geschlossene, risikoarme Änderung, die die Web-App nicht berührt — und sie ist die Voraussetzung für praktisch jeden weiteren Schritt in diesem Plan.</p>
</section>
</main>
<footer class="doc-footer">
ScanReceipts · iOS-Architekturplan · Interne Planungsnotiz, kein öffentliches Dokument
</footer>
</div>