feat: add iOS support and harden receipt scanning
This commit is contained in:
188
app/ios/README.md
Normal file
188
app/ios/README.md
Normal 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.
|
||||
Reference in New Issue
Block a user