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

188
app/ios/README.md Normal file
View File

@@ -0,0 +1,188 @@
# ScanReceipts — iOS App
Native SwiftUI client for the same backend the web app (`../..`) already
runs. There is no separate mobile backend and no separate database — this
app authenticates against the same Next.js API (`/api/**`) and the same
Postgres `users`/`receipts`/`projects` tables. A Pro account bought on the
web is a Pro account in the app the moment you log in with the same
credentials, because both clients read `users.plan` / `users.expiresAt` from
the identical row.
Written from the [architecture plan](../../docs/ios-app-plan.html) (also
published as a Claude artifact during planning) — read that first for the
*why*; this file is the *how*.
## Status
This scaffold was generated without access to Xcode/macOS (built on Windows),
so nothing here has been compiled yet. What exists:
- **Backend (already shipped, in the main repo, not this folder):** a
Bearer-token auth path alongside the existing cookie/session auth, so a
native client without a cookie jar can authenticate. See
`src/lib/auth/session.ts` (`getCurrentUser(request)`), `src/lib/auth/csrf.ts`
(`isCsrfExempt`), and the `X-Client: ios` header handling in
`src/app/api/auth/login/route.ts`. Verified end-to-end against a live dev
server (login → Bearer-authenticated `/api/receipts` round-trip → logout).
- **Sign in with Apple, backend + app, fully wired** — `POST /api/auth/apple`
(`src/app/api/auth/apple/route.ts`) verifies the identity token
(`src/lib/auth/apple.ts`, hand-rolled RS256/JWK verification against
`node:crypto`, no new dependency) and issues a session exactly like
`/api/auth/login`; `AppleSignInButton.swift` calls it via
`AppState.signInWithApple`. The token-verification logic itself (signature,
issuer, audience, expiry, tamper/forged-key rejection) was proven correct
with a 9-case self-signed-JWT test — there's no way to get a REAL Apple
identity token without a physical device and an enrolled Apple Developer
account, so that's the strongest verification possible from here. The route
wiring (HTTP status codes, CSRF exemption, rate limiting) was NOT verified
live end-to-end: the local dev server broke (every route, including
pre-existing ones, started 404ing) partway through testing because another
process was concurrently editing files in this repo (`src/lib/schema/receipt.ts`
changed mid-session) — not something caused by this change. `npx tsc
--noEmit` is clean. Re-run a live check (`curl -X POST .../api/auth/apple`)
once the repo is quiet before relying on this in production.
- **Apple In-App Purchase, backend + app, real (not stubbed) purchase
engine** — `Features/Settings/StoreKitPurchaseService.swift` is a real
StoreKit 2 implementation (product loading with actual App-Store-localized
prices, purchase, transaction verification, restore) wired into
`PaywallView`; `Configuration.storekit` lets it be tested in the Simulator
with zero Apple Developer account (see setup below). Server-side,
`POST /api/webhooks/apple` (`src/app/api/webhooks/apple/route.ts`,
`src/lib/billing/appleIAP.ts`) verifies App Store Server Notifications V2
using Apple's own official `@apple/app-store-server-library` (full x5c
certificate-chain verification against `src/lib/billing/certs/AppleRootCA-G3.cer`
— downloaded from apple.com, NOT hand-rolled crypto, deliberately unlike
the Sign-in-with-Apple JWKS check above, because chain-of-trust validation
is a much easier place to get subtly wrong) and grants/revokes
`users.plan` exactly like the existing Stripe webhook does. A purchase is
tied back to the account via `User.appleAccountToken` (iOS) /
`userIdFromAppAccountToken` (backend) — a losslessly-reversible UUID
built from the user's own id, no extra stored mapping needed.
**Verified:** the full certificate-chain verification path was proven
against a self-built 3-tier test CA (root → intermediate → leaf, signed
with the same Apple-specific X.509 extensions the library requires) —
valid chain accepted, tampered payload/untrusted root/wrong bundle
ID/wrong environment all correctly rejected (11/11 checks), and the
appAccountToken round-trip was verified byte-for-byte. **Not verified:**
an actual end-to-end purchase, since that needs a real device, an
enrolled Apple Developer account, and real App Store Connect products —
none of which exist yet. `npx tsc --noEmit` is clean.
- **Sign in with Apple, backend + app, fully wired** — `POST /api/auth/apple`
(`src/app/api/auth/apple/route.ts`) verifies the identity token
(`src/lib/auth/apple.ts`, hand-rolled RS256/JWK verification against
`node:crypto`, no new dependency) and issues a session exactly like
`/api/auth/login`; `AppleSignInButton.swift` calls it via
`AppState.signInWithApple`. The token-verification logic itself (signature,
issuer, audience, expiry, tamper/forged-key rejection) was proven correct
with a 9-case self-signed-JWT test — there's no way to get a REAL Apple
identity token without a physical device and an enrolled Apple Developer
account, so that's the strongest verification possible from here. The route
wiring (HTTP status codes, CSRF exemption, rate limiting) was NOT verified
live end-to-end: the local dev server broke (every route, including
pre-existing ones, started 404ing) partway through testing because another
process was concurrently editing files in this repo (`src/lib/schema/receipt.ts`
changed mid-session) — not something caused by this change. `npx tsc
--noEmit` is clean. Re-run a live check (`curl -X POST .../api/auth/apple`)
once the repo is quiet before relying on this in production.
- **This app's networking/model foundation** (`ScanReceipts/Networking`,
`ScanReceipts/Models`, `ScanReceipts/App`): API client, Keychain token
storage, and Codable models matching the backend's JSON exactly.
- **Visual design** (`ScanReceipts/Design/`): the web app's actual "Zenith
Silver" design system (`DESIGN (1).md`, `tailwind.config.ts`), ported —
same palette, same 4px spacing scale, 0px corner radius everywhere, no
shadows, and the SAME font files (`Resources/Fonts/*.ttf` — real Hanken
Grotesk / Inter / JetBrains Mono variable fonts pulled from the canonical
google/fonts repo, registered via `UIAppFonts`, not a system-font
approximation). `ZenithStatusBadge` ports the web dashboard's 3-tier
receipt-status badge (`StatusBadge.tsx`) color-for-color. Every screen in
`Features/` consumes this — see `Design/*.swift`'s doc comments for the
full component list (`ZenithButtonStyle`, `ZenithTextField`, `ZenithCard`,
`ZenithDivider`, `ZenithStatusBadge`, `ZenithChip`). The two native
controls Apple doesn't allow restyling (`SignInWithAppleButton`, the
VisionKit camera / `UIActivityViewController` share sheet) keep their own
required system appearance — that's an App Store requirement, not a gap.
- **Feature screens** (`ScanReceipts/Features/*`): all four areas are built
against that foundation — Auth (login/signup/email-verification/Sign in
with Apple UI), Scan (VisionKit document camera + Photos fallback + review
form), Receipts (list/search/detail-edit/delete/export + share sheet), and
Settings (profile/Pro status/projects/change password/delete
account/paywall). Cross-checked for naming collisions and that every type
`MainTabView.swift`/`RootView.swift` depend on (`AuthFlowView`,
`ScanTabView`, `ReceiptsListView`, `SettingsView`) exists with a working
zero-arg initializer. Not yet opened in Xcode. Expect the first
`xcodegen generate` + build on a Mac to surface small issues (an unused
import, a SwiftUI modifier from a newer/older SDK than assumed) — normal
for ~4,100 lines of Swift written without a compiler in the loop; nothing
here should need a structural rewrite.
## First-time setup (macOS)
```bash
brew install xcodegen
cd app/ios
xcodegen generate
open ScanReceipts.xcodeproj
```
In Xcode: **Signing & Capabilities** → set your own Team. `project.yml`
leaves `DEVELOPMENT_TEAM` blank on purpose — set it locally, don't commit it.
**To test in-app purchases without an Apple Developer account or real
money:** `Configuration.storekit` defines the three Pro products locally.
Enable it once per scheme: **Product → Scheme → Edit Scheme → Run →
Options → StoreKit Configuration → `Configuration.storekit`**. Purchases in
the Simulator then run against this local file — no App Store Connect
product configuration or real payment is involved. This is a genuinely
separate thing from the *real* products a shipped build needs registered in
App Store Connect with the exact same product identifiers (see
`PurchaseService.swift`'s `PurchaseProductID`).
Point the app at a real backend to test against by editing
`ScanReceipts/Networking/APIEnvironment.swift`: `.local` targets
`http://localhost:3901` (the web repo's `dev-verify` launch config — start it
with the web app's own tooling, a plain `npm run dev` on port 3000 also
works if you change the port here), `.production` is a placeholder domain to
replace before shipping.
## Architecture
```
ScanReceipts/
App/ Entry point, AppState (auth), root/tab routing
Networking/ APIClient, per-resource API namespaces, Keychain
Models/ Codable structs mirroring src/lib/schema/*.ts
Support/ Small standalone helpers (ISO8601 date parsing, ...)
Features/
Auth/ Login, signup, email-verification-pending, Sign in with Apple (fully wired — see below)
Scan/ VisionKit camera capture → AI extraction → review → save
Receipts/ List, detail/edit, delete, export (CSV/XLSX/PDF)
Settings/ Profile, plan/Pro status, projects (folders), logout, delete account
```
One rule that keeps this tree maintainable: **feature code never calls
`URLSession` or reads the Keychain directly.** Everything goes through
`Networking/*API.swift` (`AuthAPI`, `ReceiptsAPI`, `ProjectsAPI`, `ExportAPI`)
and `AppState`. If a screen needs a new backend call, add it to the relevant
`*API.swift` file rather than reaching around it.
## What's deliberately NOT done yet
- **An actual App Store Connect listing.** The purchase engine and webhook
are both real, but there is still no Apple Developer Program enrollment,
no App Store Connect app record, and no real product configuration — so
`APPLE_APP_APPLE_ID` stays unset (see `.env.example`) and real-money
purchases genuinely cannot happen yet. `Configuration.storekit` covers
local Simulator testing in the meantime.
- **Push notifications** for "scan finished" — not built; a nice-to-have per
the plan, not required for a working v1.
- **iPad layout** — `project.yml` targets iPhone only (`TARGETED_DEVICE_FAMILY: "1"`).
## Testing against the shared database
Because this app and the web dashboard hit the same API, the fastest way to
sanity-check a screen while developing is: create/verify a user via the web
app's normal signup flow (or `node scripts/create-admin.ts` in the main repo
for an instant Pro account), then log into the iOS app (Simulator, `.local`
environment) with the same credentials. Anything synced from one side shows
up on the other on next refresh — there is no separate mobile dataset to
seed.