docs(manual): batch 7 review — iOS 'DefaultEntitlementChecker actor' doesn't exist; gated features list was wrong

The iOS Server-verified entitlements section said:
  'The iOS DefaultEntitlementChecker actor does not observe Firestore
   entitlements. It reads RevenueCat CustomerInfo only, via
   Purchases.shared.customerInfoStream.'

But there is no DefaultEntitlementChecker actor on iOS. The actual file
is iphone/Closer/Core/Billing/BillingService.swift (a
@unchecked Sendable class with a customerInfoStream property that
exposes Purchases.shared.customerInfoStream). Replaced the manual
with the real class + file path.

The 'Gated features (current list)' section also listed 7 things, 4
of which are NOT actually gated in the source:
  - 'Full answer history (free shows last 7; premium shows all)' -
    AnswerHistoryViewModel has no CouplePremiumChecker or
    EntitlementChecker injection. The 7-answer cap is not implemented.
  - 'Custom questions' - no CustomQuestion / CustomQuestionViewModel
    exists in the codebase.
  - 'Private notes' - no PrivateNote / PrivateNoteViewModel exists.
  - 'Extra categories (beyond the free tier)' - no separate count cap;
    this is the same as 'Premium wheel categories' which IS gated.

The 4 features that ARE gated, cross-checked against the source:
  - Premium question packs (QuestionPackLibraryViewModel injects
    CouplePremiumChecker; filter is PackFilter.PREMIUM + per-category
    access == 'premium' check).
  - Premium wheel categories (CategoryPickerViewModel - isLocked =
    category.access == 'premium' && !hasPremium).
  - Date Match premium ideas (DateMatchViewModel, R12 / A-201 landmine).
  - Full spin-wheel session history (WheelHistoryViewModel observes
    premiumChecker.isPremium() and limits the free tier).
  - Chat media (ConversationViewModel, R24) - missing from the manual
    but exists; added.

Replaced the stale 7-item list with the real list, called out the 4
non-existent gates explicitly, and added the chat-media gate that
was missing. Pattern remains: inject CouplePremiumChecker in the VM,
navigate to paywallScreen() on false.

Other Batch 7 claims verified clean:
- RevenueCat SDK version (purchases:8.20.0), RC_API_KEY sources,
  release-build fail-fast guard - all match build.gradle.kts.
- iOS Secrets enum reads RC_API_KEY from Info.plist in CloserApp.swift.
- RevenueCat identity link via Purchases.logIn(firebaseAuth.uid) and
  Purchases.logOut() on sign-out (commit b99a8338) - present in
  RevenueCatBillingRepository.
- BillingException typed mapping for PurchasesErrorCode - present.
- EntitlementChecker interface (isPremium Flow, hasPremium suspend,
  onCustomerInfoUpdated) matches core/billing/EntitlementChecker.kt.
- revenueCatWebhook is onRequest (not onCall), auth is Ed25519,
  RETURNS 200/400/401/500 as the manual describes.
This commit is contained in:
null 2026-07-08 23:27:16 -05:00
parent aa35614840
commit ceb98f4abd
1 changed files with 19 additions and 10 deletions

View File

@ -884,7 +884,7 @@ The Android `FirestoreEntitlementChecker` is the source of truth for premium sta
3. Treats an `expiresAt` in the past as `premium = false`. 3. Treats an `expiresAt` in the past as `premium = false`.
4. **Fails closed**: on Firestore listener error, `isPremium()` emits `false` (i.e. "not premium") rather than guessing. 4. **Fails closed**: on Firestore listener error, `isPremium()` emits `false` (i.e. "not premium") rather than guessing.
The iOS `DefaultEntitlementChecker` actor does **not** observe Firestore entitlements. It reads RevenueCat `CustomerInfo` only, via `Purchases.shared.customerInfoStream`. **iOS premium state is therefore client-verified, not server-verified** - this is a known gap that should be closed before production. The iOS `BillingService` (`iphone/Closer/Core/Billing/BillingService.swift`) does **not** observe Firestore entitlements. It reads RevenueCat `CustomerInfo` only, via `Purchases.shared.customerInfoStream` exposed as `BillingService.customerInfoStream`. **iOS premium state is therefore client-verified, not server-verified** - this is a known gap that should be closed before production.
The Android app uses both `EntitlementChecker` (per-user server-verified premium) and `CouplePremiumChecker` (couple-shared premium: true if either partner has premium). `CouplePremiumChecker` is the drop-in used for couple-wide gates like chat media, shared games, and date features so a single subscription covers both partners. The Android app uses both `EntitlementChecker` (per-user server-verified premium) and `CouplePremiumChecker` (couple-shared premium: true if either partner has premium). `CouplePremiumChecker` is the drop-in used for couple-wide gates like chat media, shared games, and date features so a single subscription covers both partners.
@ -914,17 +914,26 @@ Callers collect `isPremium()` reactively rather than caching a one-time snapshot
The `EntitlementChecker.isPremium()` Flow is collected by feature-level ViewModels; features that gate render the paywall deep-link on a `false` value rather than throwing or showing empty state. The `EntitlementChecker.isPremium()` Flow is collected by feature-level ViewModels; features that gate render the paywall deep-link on a `false` value rather than throwing or showing empty state.
**Gated features (current list):** **Gated features (current list, as of 2026-07-08):**
- Premium question packs (`QuestionPackRepositoryImpl.isPremiumGate()`) - **Premium question packs** - `QuestionPackLibraryViewModel` injects `CouplePremiumChecker`; the pack filter `PackFilter.PREMIUM` plus each `QuestionCategory.access == "premium"` check.
- Full answer history (free shows last 7; premium shows all) - **Premium wheel categories** - `CategoryPickerViewModel` sets `isLocked = category.access == "premium" && !hasPremium`; tapping a locked category routes to the paywall.
- Custom questions - **Date Match premium ideas** - `DateMatchViewModel` (R12 fix for **A-201**); swiping LOVE/MAYBE on a premium idea when neither partner is premium emits `paywallRequired` and routes to the paywall.
- Private notes - **Full spin-wheel session history** - `WheelHistoryViewModel` observes `premiumChecker.isPremium()` and limits the free tier to the most recent sessions.
- Extra categories (beyond the free tier) - **Chat media** - `ConversationViewModel` (R24) gates image/voice upload on `couplePremiumChecker` (this is the canonical couple-shared gate - "either partner has Premium unlocks chat media for the couple").
- Full spin-wheel session history
- Future AI-assisted question suggestions
**How to gate a new feature:** **NOT currently gated** (the v0.2.1 manual claimed these but the source has no gate):
- "Full answer history (free shows last 7; premium shows all)" - `AnswerHistoryViewModel` has NO `CouplePremiumChecker` or `EntitlementChecker` injection. The 7-answer cap is not implemented.
- "Custom questions" - no `CustomQuestion` / `CustomQuestionViewModel` exists in the codebase.
- "Private notes" - no `PrivateNote` / `PrivateNoteViewModel` exists in the codebase.
- "Extra categories (beyond the free tier)" - the manual conflates this with the "Premium wheel categories" gate above. There is no separate category count cap.
**Future (planned):**
- AI-assisted question suggestions (mentioned in the v0.1.0 plan; no source yet).
If you add a new premium feature, mirror the established pattern: inject `CouplePremiumChecker` (or `EntitlementChecker` for the rare per-user gate), collect `isPremium()` in the VM, navigate to `paywallScreen()` on `false`. Server-side: any new premium-only Cloud Function must verify `users/{uid}/entitlements/premium` before doing work. Do not trust the client flag for server-side gating.**How to gate a new feature:**
1. Inject `EntitlementChecker` into the relevant ViewModel or Repository. 1. Inject `EntitlementChecker` into the relevant ViewModel or Repository.
2. On entry, collect `isPremium()`. If `false`, navigate to the paywall route (`paywallScreen()` in `AppNavigation`) with a returnTo argument. Do not silently fail or render empty state. 2. On entry, collect `isPremium()`. If `false`, navigate to the paywall route (`paywallScreen()` in `AppNavigation`) with a returnTo argument. Do not silently fail or render empty state.