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 theX-Client: iosheader handling insrc/app/api/auth/login/route.ts. Verified end-to-end against a live dev server (login → Bearer-authenticated/api/receiptsround-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 againstnode:crypto, no new dependency) and issues a session exactly like/api/auth/login;AppleSignInButton.swiftcalls it viaAppState.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.tschanged mid-session) — not something caused by this change.npx tsc --noEmitis 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.swiftis a real StoreKit 2 implementation (product loading with actual App-Store-localized prices, purchase, transaction verification, restore) wired intoPaywallView;Configuration.storekitlets 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 againstsrc/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/revokesusers.planexactly like the existing Stripe webhook does. A purchase is tied back to the account viaUser.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 --noEmitis 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 againstnode:crypto, no new dependency) and issues a session exactly like/api/auth/login;AppleSignInButton.swiftcalls it viaAppState.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.tschanged mid-session) — not something caused by this change.npx tsc --noEmitis 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 viaUIAppFonts, not a system-font approximation).ZenithStatusBadgeports the web dashboard's 3-tier receipt-status badge (StatusBadge.tsx) color-for-color. Every screen inFeatures/consumes this — seeDesign/*.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 /UIActivityViewControllershare 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 typeMainTabView.swift/RootView.swiftdepend on (AuthFlowView,ScanTabView,ReceiptsListView,SettingsView) exists with a working zero-arg initializer. Not yet opened in Xcode. Expect the firstxcodegen 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_IDstays unset (see.env.example) and real-money purchases genuinely cannot happen yet.Configuration.storekitcovers 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.ymltargets 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.