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:
parent
aa35614840
commit
ceb98f4abd
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue