189 lines
11 KiB
Markdown
189 lines
11 KiB
Markdown
# 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.
|