Files
scan-receipts/app/ios/README.md

11 KiB

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 (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 wiredPOST /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 engineFeatures/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 wiredPOST /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)

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