# Plan: Real Database, JWT Enforcement, Free Trial + Pay-Per-Download

Started: 2026-09-03. Status legend: `[ ]` not started · `[~]` in progress · `[x]` done.

## What exists already (found while scoping this)

- **JWT session auth is already half-built.** `server/src/lib/auth.js` already
  verifies Google/Apple identity tokens and issues this server's own 30-day
  session JWT (`signSessionToken`/`requireAuth`) — but **no route actually
  uses `requireAuth` except `/api/auth/me`**, and the client stores the
  token (`SESSION_TOKEN_KEY` in `AuthContext.tsx`) but never sends it on any
  request. `/info`, `/download`, `/status/:jobId`, `/jobs/:jobId` are
  completely unauthenticated today — anyone who knows the server's LAN IP
  can call them with no token at all.
- **User storage is a JSON file** (`server/src/lib/userStore.js`,
  `server/data/users.json`), explicitly written as a stopgap: "mainly so a
  future in-app-purchase feature has a stable user id to attach entitlements
  to" (from an earlier session's commit). This is that feature now.
- No Docker, no DB client library, no `docker-compose.yml` anywhere in the
  repo yet.

## Decisions made (2026-09-03)

- **Self-built receipt validation on our own server**, not RevenueCat — the
  user wants full control ("turn it off/on from database"), matching how
  the rest of this project is built.
- **Real MySQL via Docker**, not the existing JSON-file pattern — user was
  under the impression a real DB already existed. Also asked for JWT auth
  "properly implemented" given payments are involved, and expects more APIs
  later.
- **Free trial: 10 downloads, lifetime, one-time per account** (not a
  periodic reset).
- **Purchases: single ₹1 consumable credit only for v1** — no bundles yet,
  can be added later without changing the architecture (just another
  product ID mapping to a different `credits_granted` value).
- **DB access: raw `mysql2` with hand-written SQL**, not an ORM (Prisma/
  Knex/Drizzle) — matches this codebase's existing style (no ORM/heavy
  abstraction used anywhere else; `userStore.js`/`jobStore.js` are both
  plain hand-rolled stores). Worth revisiting if the schema grows a lot
  once "more APIs later" materializes.
- **Client library for IAP: `expo-iap`** (actively maintained, Expo-native,
  supports consumables) over `react-native-iap` (same author, less
  Expo-integrated) — `expo-in-app-purchases` (Expo's old library) is
  confirmed deprecated, not an option.
- **Money stored as integer minor units** (paise, not rupees-as-float) in
  the DB — avoids floating-point rounding bugs on financial data, standard
  practice.
- **"Turn off/on from database" is now literal**: `app_config` table with a
  `paywall_enabled` row. No admin API planned for v1 — toggling means
  editing that row directly (a MySQL client, or Adminer, included in the
  Docker Compose setup for convenience). A protected admin endpoint can be
  added later if remote toggling without DB access becomes worth it.

## Schema (MySQL)

```sql
CREATE TABLE users (
  id CHAR(36) PRIMARY KEY,              -- keeps existing UUID shape so old session JWTs' userId claims still resolve
  provider VARCHAR(16) NOT NULL,        -- 'google' | 'apple'
  provider_user_id VARCHAR(255) NOT NULL,
  email VARCHAR(255) NULL,
  name VARCHAR(255) NULL,
  avatar_url TEXT NULL,
  free_downloads_used INT NOT NULL DEFAULT 0,
  purchased_credits INT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_provider_identity (provider, provider_user_id)
);

CREATE TABLE purchases (
  id CHAR(36) PRIMARY KEY,
  user_id CHAR(36) NOT NULL,
  platform VARCHAR(16) NOT NULL,        -- 'ios' | 'android'
  product_id VARCHAR(255) NOT NULL,
  transaction_id VARCHAR(255) NOT NULL, -- platform's own transaction id
  credits_granted INT NOT NULL,
  status VARCHAR(16) NOT NULL DEFAULT 'verified',
  raw_receipt TEXT NULL,                -- kept for audit/debugging
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uniq_transaction (platform, transaction_id), -- prevents double-crediting a replayed receipt
  FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE TABLE app_config (
  `key` VARCHAR(64) PRIMARY KEY,
  value TEXT NOT NULL,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- seeded: paywall_enabled='true', free_download_limit='10', price_per_download_paise='100'
```

## Enforcement logic (at `/api/download` time)

1. If `app_config.paywall_enabled` is `false` → allow unconditionally.
2. Else if `user.free_downloads_used < free_download_limit` → allow,
   increment `free_downloads_used`.
3. Else if `user.purchased_credits > 0` → allow, decrement
   `purchased_credits`.
4. Else → `402 Payment Required`; client shows the buy-a-credit flow.

## Task list

### Phase 1 — Docker + MySQL + migrate user storage — DONE
- [x] `docker-compose.yml`: MySQL 8 service (persisted volume) + Adminer
      for convenient DB browsing/manual toggling. Host port **3308**, not
      3306/3307 — both already used by other unrelated local projects on
      this machine (confirmed via `lsof`/`docker ps` before picking it).
- [x] Schema migration script (`server/db/schema.sql`, auto-runs via
      MySQL's `docker-entrypoint-initdb.d` on first container start) — the
      three tables above, seeded `app_config` rows.
- [x] Added `mysql2`; `server/src/lib/db.js` connection pool +
      `assertDbReachable()`, called at server boot so a misconfigured/down
      DB fails loudly at startup instead of on the first request.
- [x] Rewrote `userStore.js` to query MySQL instead of `users.json` — same
      exported function signatures (`upsertUser`, `getUserById`), now
      async, so `auth.js` only needed `await` added, not a rewrite.
- [x] One-time migration script (`server/scripts/migrate-users-json.js`),
      run once: copied both real users from `data/users.json` into MySQL
      **preserving their exact `id`** — meaning any session JWT already
      issued (which encodes that id) stays valid, no forced re-login.
- [x] Verified: server boots and logs `[DB] Connected.`; a session token
      constructed for the real signed-in user resolves correctly via
      `GET /api/auth/me` against the new DB, returning the same identity
      — confirms session continuity was preserved by the migration.

### Phase 2 — Wire up JWT enforcement — DONE
- [x] Client: `src/lib/apiFetch.ts` — a shared `authFetch()` wrapper that
      attaches `Authorization: Bearer <token>`, backed by a module-level
      token (not React state — kept in sync by `AuthContext` on
      restore/sign-in/sign-out via `setAuthToken()`, since the calling
      code doesn't need to re-render when it changes). Replaced the direct
      `fetch()` calls to `/info` (`index.tsx`, `browse.tsx`), `/download`,
      `/status/:jobId`, `/jobs/:jobId` (all three in `MediaContext.tsx`).
- [x] Server: added `requireAuth` to those four route handlers in `api.js`.
- [x] Verified: a request with no token gets a clean `401 Missing
      Authorization header` (not a crash); a request with a valid token
      succeeds. Then a full real download through the actual running app
      (paste a Shorts URL → format sheet loads → pick MP3 128kbps →
      download completes → the authenticated cleanup `DELETE
      /api/jobs/:id` call succeeds) — confirms the client's token wiring
      works end-to-end, not just against a manually-crafted test token.

### Phase 3 — Free trial + paywall enforcement — DONE
- [x] Implemented the 4-step enforcement logic in `/api/download`, backed
      by two atomic `UPDATE ... WHERE <still has room>` queries
      (`consumeDownloadEntitlement` in `userStore.js`) — free trial first,
      then purchased credits — safe under concurrent requests since MySQL
      only reports a row affected if the WHERE condition still held at the
      moment of the write, no read-then-write race.
- [x] Refunds on failure: if a charged job's yt-dlp run actually fails,
      `refundDownloadEntitlement` undoes whatever was charged
      (`jobStore.createJob` now records `userId`/`entitlement` per job so
      the failure handler knows what to refund) — a failed download
      shouldn't cost the user anything.
- [x] `GET /api/config` (authenticated): returns `paywallEnabled`,
      `freeDownloadLimit`, `freeDownloadsUsed`, `freeDownloadsRemaining`,
      `purchasedCredits`, `pricePerDownloadPaise`.
- [x] Client: Settings screen now shows "N free downloads left" (or "X
      purchased credits" once any exist), fetched live from `/api/config`.
      A full paywall *modal* is deliberately not built yet — the
      server's 402 message already surfaces via the existing generic
      download-error alert, and there's no purchase button to put in a
      fancier prompt until Phase 4 exists.
- [x] Verified directly against the real DB and the real running app:
      exhausting the free trial blocks with a clean 402 + the exact
      message; adding a purchased credit unblocks it and the credit gets
      consumed (confirmed via a DB read after); flipping
      `paywall_enabled` to `false` in the DB unblocks a still-exhausted
      user immediately, no rebuild — the literal ask ("turn it off/on
      from database"), confirmed working. Settings screen shows "10 free
      downloads left" live via the real app.

### Phase 4 — expo-iap purchase flow + server-side receipt verification — MOSTLY DONE
Real end-to-end testing (an actual purchase sheet completing) still needs
setup work from the user — a consumable product in App Store Connect
(iOS) and/or Google Play Console (Android), or a local StoreKit
Configuration file for offline iOS testing — same shape as the existing
`docs/GOOGLE_APPLE_SIGNIN_SETUP.md` credentials gap. Everything on our
side (client + server code) is built, wired, and verified.
- [x] `npx expo install expo-iap` (5.5.0) + `pod install`
      (`RCT_NEW_ARCH_ENABLED=1`) + native rebuild
      (`expo run:ios --no-install`) — linked cleanly, app launches fine.
- [x] Client (`src/app/settings.tsx`): `DownloadEntitlementSection` now
      also renders a "Buy <price>" button (real StoreKit price via
      `useIAP().fetchProducts`, falling back to "₹1" before that
      resolves). Tapping it calls `requestPurchase({ request: { apple: {
      sku } }, type: 'in-app' })`; `onPurchaseSuccess` posts to
      `/api/purchases/verify`, only calls `finishTransaction({
      isConsumable: true })` *after* the server confirms the credit
      landed (so a verify failure leaves the transaction outstanding and
      retryable, not silently discarded); `onPurchaseError` shows an
      alert (silently ignored for user-cancelled). Product id lives in
      `src/config.ts` (`DOWNLOAD_CREDIT_PRODUCT_ID_IOS`).
- [x] Server: `POST /api/purchases/verify` (authenticated,
      `src/routes/api.js`) — validates the body, checks `productId`
      against the known SKU, calls `verifyIosPurchase`
      (`src/lib/appleVerify.js`), records the purchase
      (`src/lib/purchaseStore.js`, idempotent via `uniq_transaction`),
      credits `purchased_credits` (`userStore.addPurchasedCredits`) only
      on a first-time insert. Wrapped in try/catch — a DB error here
      returns a clean 500 rather than crashing the process (a purchase
      already verified as real must never be silently dropped, but also
      must not take the whole server down).
- [x] `purchases.status` stored as a `TINYINT`, not a string — a fixed
      set of outcomes, read/written only through the exported
      `PurchaseStatus` enum (`VERIFIED = 1`, `VERIFIED_DEV_MODE = 2`) in
      `purchaseStore.js`, per the user's explicit steer away from a
      free-text status column ("give stress while search... give a
      number and declare enum around it").
- [x] Real Apple verification (App Store Server API, JWT-signed with
      `APPLE_ISSUER_ID`/`APPLE_IAP_KEY_ID`/`APPLE_IAP_PRIVATE_KEY`) isn't
      configured yet — `appleVerify.js` auto-falls-back to an explicitly
      logged **DEV MODE** when any of those three env vars are unset: it
      decodes the purchase token's JWS payload and sanity-checks
      productId/transactionId WITHOUT verifying the signature. Clearly
      commented as unsafe for production; the real verification call is
      stubbed with a `throw` + TODO for once credentials exist.
- [x] Verified against the real running server + DB (constructed a
      session JWT the same way prior phases did): missing fields → 400;
      unknown productId → 400; a well-formed dev-mode token → 200 +
      `purchasedCredits` incremented once; **replaying the identical
      transactionId → still 200, credits stay at 1 (not double-charged)**
      — confirms `uniq_transaction` idempotency for real; a token whose
      embedded productId/transactionId don't match the claimed ones →
      402. Caught and fixed a real bug this way: the first live test
      crashed the whole server (`status` column too short for
      `'verified_dev_mode'`, and the async route had no try/catch —
      Express 4 doesn't route a rejected promise to the error middleware
      on its own) — fixed by both widening/retyping the column (now the
      enum above) and wrapping the route.
      Test rows/credits cleaned up from the DB afterward.
- [x] Verified against the real app on the simulator: tapping "Buy more
      credits" reaches real StoreKit and fails cleanly with "SKU not
      found" (no product configured yet, expected) — not a crash.
      Confirms the whole client chain (button → `requestPurchase` →
      native StoreKit call) is wired correctly end-to-end; only a real
      product/StoreKit Configuration is missing to complete a purchase.
- [x] iOS: built a local StoreKit Configuration file
      (`ios-storekit/Products.storekit`, tracked in git — NOT under the
      gitignored/CNG-generated `ios/`) plus a small custom Expo config
      plugin (`plugins/withStoreKitConfig.js`) that copies it into
      `ios/<project>/` and wires the generated scheme's `<LaunchAction>`
      to reference it on every `prebuild`, matching the "no hand-editing
      `ios/`" convention. **Confirmed working as far as the wiring goes**
      (verified the scheme XML and copied file are both correct after a
      clean prebuild) **but discovered a hard platform limitation**: Xcode
      only activates a scheme's StoreKit Configuration when the app is
      launched via Xcode's own Run button (Cmd+R) — `xcodebuild`/`simctl
      launch`, which is what `expo run:ios` uses under the hood, does NOT
      trigger it, confirmed by two independent sources (RevenueCat
      community, Apple Developer Forums; this is a known, unfixed Apple
      limitation, not a bug in our plugin). So real simulated StoreKit
      prices/purchases need opening `ios/myplayer.xcworkspace` in Xcode
      and pressing Run at least once — `expo run:ios` remains fine for
      all other day-to-day iteration.
- [ ] Real Apple App Store Server API credentials (Issuer ID, Key ID,
      `.p8` key) — user needs to generate these in App Store Connect >
      Users and Access > Keys > In-App Purchase. Once set in `.env`,
      `appleVerify.js` needs its real-verification branch implemented
      (currently a stubbed `throw` + TODO).
- [x] Android: full parallel implementation done and verified. See
      Phase 6 below.

### Phase 5 — Global buy-credits modal + multi-tier products — DONE
User feedback after seeing Phase 4's single-₹1-button-in-Settings flow:
a blocked download shouldn't dead-end at a plain alert, credit tiers
should be a real 1/10/100 catalog, and the whole product list should be
server-defined rather than hardcoded client-side.
- [x] `credit_products` DB table (`server/db/schema.sql`) — the product
      catalog itself: `product_id`, `platform`, `credits_granted`,
      `display_order`, `active`. Seeded 3 iOS tiers, 1:1 with rupees (1
      credit/₹1, 10/₹10, 100/₹100 — no bulk discount for v1, easy to
      change later since it's just DB rows). The server is the source of
      truth for which SKUs exist and how many credits each is worth;
      actual displayed price still comes from StoreKit at purchase time,
      never from this table.
- [x] `productStore.js` + `GET /api/purchases/products?platform=ios`
      (authenticated) — returns the active catalog, or `[]` whenever the
      paywall is off (so a client that never shows purchase UI in that
      state doesn't even need its own check).
- [x] `POST /api/purchases/verify` no longer hardcodes "1 credit per
      purchase" — looks up `credits_granted` for the given `productId` via
      `productStore.getProductById`, rejects unknown products. Never
      trusts a client-supplied credit amount for something that charges
      money.
- [x] Client: `EntitlementContext.tsx` (new) — the single owner of
      entitlement state (`GET /api/config`), the product catalog (`GET
      /api/purchases/products`), and the one `useIAP()` instance for the
      whole app (was previously duplicated per-component). Exposes
      `openBuyModal()`/`closeBuyModal()` so anything can trigger the
      modal without prop-drilling.
- [x] `BuyCreditsModal.tsx` (new) — the single global purchase surface,
      rendered once in `_layout.tsx` (not per-screen). Renders whatever
      `GET /api/purchases/products` returns, merged with live StoreKit
      pricing (`useIAP().products[].displayPrice`) once the store
      connects.
- [x] Settings: `DownloadEntitlementSection` simplified to read
      `useEntitlement()` instead of its own local fetch/IAP logic; "Buy
      ₹1" replaced with "Buy more credits" → `openBuyModal()`. Also fixed
      a real staleness bug found while wiring this up: the entitlement
      display only fetched once on mount, so it could show a stale count
      after navigating away and back — now refetches via
      `useFocusEffect` every time Settings comes into focus.
- [x] `MediaContext.searchAndDownload`: a `402 PAYMENT_REQUIRED` response
      now calls `openBuyModal()` directly instead of a dead-end
      `alert()` — the literal ask ("I didn't like that we just leave
      user with alert to buy credits").
- [x] Paywall-off invisibility preserved end-to-end, not just the old
      single button: `openBuyModal()` itself no-ops if
      `!config.paywallEnabled` (belt-and-suspenders), the products
      endpoint returns `[]`, and Settings' entire entitlement row is
      still gated on the same flag — verified live by toggling
      `paywall_enabled` off in the DB and confirming the row disappears
      completely.
- [x] Verified live end-to-end: the modal renders all 3 tiers with
      correct credit/download counts pulled from the DB; tapping a tier
      reaches real StoreKit and fails cleanly ("SKU not found", expected
      — no product configured yet); the blocked-download path opens the
      modal directly with a contextual message ("You're out of free
      downloads — pick a pack to keep going."); toggling the paywall off
      then back on and testing both trigger points confirmed correct
      behavior for each. Caught and fixed two real bugs along the way: a
      require cycle from `EntitlementContext` rendering `BuyCreditsModal`
      internally (moved the modal's render to `_layout.tsx` instead,
      one-directional import), and a duplicate-alert bug where both
      `useIAP`'s `onPurchaseError` and `purchaseProduct`'s own try/catch
      alerted on the same failed `requestPurchase()` call (fixed by
      making the try/catch silent — `onPurchaseError` gives the more
      specific message).

### Phase 6 — Android IAP — server + client wired, native build verified live
User attached a real Android device (Samsung Galaxy M13, `adb` serial
`RZCT928WMVF`) and asked to start Android IAP support. `android/` in
this repo predated `expo-iap` (built before Phase 4), so needed a clean
`expo prebuild --platform android --clean` to pick it up — this also
wiped the gitignored, machine-local `android/local.properties`
(`sdk.dir=...`), which had to be regenerated before Gradle would build.
- [x] `credit_products` DB rows for `platform = 'android'`: `credits_1`
      / `credits_10` / `credits_100`, 1/10/100 credits — same 1:1 pricing
      as iOS. Android product ids are plain names (`credits_1`, not a
      reverse-DNS string) since Play Console scopes them to the app's own
      package already; iOS and Android rows coexist in the same table,
      distinguished by the `platform` column.
- [x] `productStore.getProductById` made platform-aware
      (`WHERE product_id = ? AND platform = ?`, was id-only) — this is
      payment code, so a purchase's platform claim must actually be
      checked against the resolved product, not just used to pick which
      verifier to call. Verified live: submitting an iOS product id while
      claiming `platform: 'android'` correctly 400s as unknown, doesn't
      silently resolve to the iOS row.
- [x] `server/src/lib/googlePlayVerify.js` (new) — mirrors
      `appleVerify.js`'s structure. Real verification (Google Play
      Developer API, service-account JWT auth) needs
      `GOOGLE_PLAY_SERVICE_ACCOUNT_EMAIL`/`_PRIVATE_KEY`, not configured
      yet — same DEV MODE fallback pattern as iOS, but even more limited:
      an Android `purchaseToken` is an **opaque string** from Play
      Billing, not a JWS like StoreKit's, so there is nothing to decode
      or sanity-check locally at all — dev mode can only confirm a token
      was supplied, not that it's real. Clearly commented as unsafe for
      production.
- [x] `POST /api/purchases/verify` now branches on `platform` (`'ios'` →
      `verifyIosPurchase`, `'android'` → `verifyAndroidPurchase`) instead
      of rejecting anything non-iOS.
- [x] Client: `EntitlementContext.purchaseProduct` branches the
      `requestPurchase` request shape by `Platform.OS` — iOS sends
      `{ apple: { sku } }`, Android sends `{ google: { skus: [sku] } }`
      (Play Billing's request takes an array). Everything else
      (`finishTransaction`, the verify POST, the modal UI) was already
      platform-generic from Phase 5's design — no other client changes
      needed for Android.
- [x] Verified server-side against the real running server + DB with the
      same method as prior phases (constructed session JWT + curl):
      `GET /api/purchases/products?platform=android` returns the 3
      Android rows correctly and independently of the iOS rows; a
      well-formed Android dev-mode verify call credits the account
      correctly; replaying the identical transaction is idempotent (not
      double-credited); an iOS product id submitted under
      `platform: 'android'` is correctly rejected as unknown. Test
      rows/credits cleaned up from the DB afterward.
- [x] Verified live on the real physical device: `expo prebuild
      --platform android --clean` added the `com.android.vending.BILLING`
      permission and wired the `expo-iap`/OpenIAP Gradle dependencies
      automatically; a full Gradle build succeeded (`BUILD SUCCESSFUL`,
      `expo-iap`'s Kotlin sources compiled clean); the APK installed and
      launched on the device with no crash (confirmed via `adb logcat` —
      process alive, no FATAL/AndroidRuntime errors) and loaded the JS
      bundle over the LAN correctly.
- [ ] **Full on-device purchase-flow testing (tapping a tier, confirming
      credits update) is blocked on a pre-existing, unrelated gap**: this
      device's app shows "Google sign-in isn't configured yet" —
      `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID`/`EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID`
      are blank in `.env` (see `docs/GOOGLE_APPLE_SIGNIN_SETUP.md`, a
      gap that predates this phase entirely — the iOS simulator only
      ever got past this because of an old cached session token, not
      because sign-in is actually configured). Every screen past login
      is gated behind sign-in, so this blocks ALL functional Android UI
      testing, not just IAP. Once Google Sign-In is configured, the
      Android purchase flow should be exercisable the same way iOS's was
      (tap a tier → reaches Play Billing → fails cleanly with an
      unconfigured-product error, since no real Play Console product
      exists yet either — expected, same category as iOS's "SKU not
      found").
- [ ] Real Google Play Developer API credentials (service account) —
      user needs to create one in Play Console > Setup > API access.
      Once set in `.env`, `googlePlayVerify.js` needs its
      real-verification branch implemented (currently a stubbed `throw`
      + TODO), matching `appleVerify.js`'s TODO for Apple credentials.
- [ ] Real Play Console products (the 3 credit tiers) + at minimum a
      License Testing tester account, or an Internal Testing track —
      Android purchase testing needs a real Play Console listing even
      for sandbox purchases, same category of external-setup gate as
      iOS's App Store Connect products.

## Notes / running log

- 2026-09-03: Plan created after scoping the existing auth/user-storage
  code — found that JWT session tokens are already issued but never
  enforced or sent by the client, which reframes Phase 2 as "close an
  existing gap" rather than "build JWT from scratch." User's original ask
  (free trial + pay-per-download + remote toggle) expanded significantly
  once "turn it off/on from database" turned out to mean they expected a
  real database that doesn't exist yet — now also covers Docker+MySQL+JWT
  enforcement as prerequisites, not just the paywall logic itself.
- 2026-09-03: Phase 4 built — `expo-iap` client purchase flow and the
  server's `/api/purchases/verify`, in dev-mode (unsigned JWS decode)
  since real Apple credentials don't exist yet. `purchases.status` was
  originally a free-text VARCHAR; changed to a `TINYINT` + `PurchaseStatus`
  enum per direct user feedback partway through. Remaining Phase 4 items
  (StoreKit Configuration file, real Apple credentials, Android) are all
  gated on setup work outside this codebase.
- 2026-09-03: Phase 5 built, same day, right after asking what happens
  when a download is blocked prompted direct feedback: no dead-end
  alert, a proper global buy-credits modal with 1/10/100-credit tiers,
  server-defined products, and the Settings button opening the same
  modal instead of buying a fixed ₹1 directly. Also fixed a real
  staleness bug in Settings' entitlement display (only fetched on
  mount) and two bugs introduced while building this phase (a require
  cycle, a duplicate purchase-failure alert) — both caught via live
  testing, not just code review.
- 2026-09-04: Investigated why the buy-credits modal only showed credit
  counts, not prices — root cause: no App Store Connect product or
  StoreKit Configuration backs the SKUs yet, so StoreKit has nothing to
  return. Built the local StoreKit Configuration path (`ios-storekit/`
  + `plugins/withStoreKitConfig.js`) but discovered it can only ever
  activate via Xcode's own Run button, not `expo run:ios` — a real
  Apple/Xcode limitation, not something fixable in this codebase.
  Same day, user attached a physical Android device and asked to start
  Android support — built the full parallel server + client
  implementation (Phase 6), verified server-side thoroughly and
  confirmed the native Android build installs and runs cleanly on the
  device, but full on-device purchase-flow testing is blocked on the
  pre-existing, already-documented Google Sign-In gap (unrelated to
  this work — every screen past login needs it).
