# 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.