From ceb98f4abdf483b6e42322508373f545697d7054 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 8 Jul 2026 23:27:16 -0500 Subject: [PATCH] =?UTF-8?q?docs(manual):=20batch=207=20review=20=E2=80=94?= =?UTF-8?q?=20iOS=20'DefaultEntitlementChecker=20actor'=20doesn't=20exist;?= =?UTF-8?q?=20gated=20features=20list=20was=20wrong?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/Engineering_Reference_Manual.md | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index ad673632..11b54c18 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -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`. 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. @@ -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. -**Gated features (current list):** +**Gated features (current list, as of 2026-07-08):** -- Premium question packs (`QuestionPackRepositoryImpl.isPremiumGate()`) -- Full answer history (free shows last 7; premium shows all) -- Custom questions -- Private notes -- Extra categories (beyond the free tier) -- Full spin-wheel session history -- Future AI-assisted question suggestions +- **Premium question packs** - `QuestionPackLibraryViewModel` injects `CouplePremiumChecker`; the pack filter `PackFilter.PREMIUM` plus each `QuestionCategory.access == "premium"` check. +- **Premium wheel categories** - `CategoryPickerViewModel` sets `isLocked = category.access == "premium" && !hasPremium`; tapping a locked category routes to the paywall. +- **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. +- **Full spin-wheel session history** - `WheelHistoryViewModel` observes `premiumChecker.isPremium()` and limits the free tier to the most recent sessions. +- **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"). -**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. 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.