Subscriptions
Account-bound Pro entitlement through RevenueCat and the Supabase read model.
The Kotlin template sells Pro through Google Play and RevenueCat, but the app
does not treat RevenueCat as the entitlement source of truth. Purchases are
written into the shared Supabase subscriptions table, and app gates read that
account row.
What it provides
| Surface | Status | Implementation |
|---|---|---|
| RevenueCat SDK lifecycle | Integrated | PurchasesController configures RevenueCat when REVENUECAT_ANDROID_KEY is present |
| Account identity | Integrated | RevenueCat logs in with the Supabase user UUID as app_user_id |
| Entitlement state | Integrated | EntitlementController reads public.subscriptions through SubscriptionRepository |
| Paywall | Integrated | PaywallScreen renders the active offering's packages |
| Manage subscription | Integrated | ManageSubscriptionScreen shows RevenueCat/Google Play management state |
| Promo codes | Integrated | RedeemCodeScreen opens the Google Play redeem URL |
| Pro content example | Removable demo | feature/guide/ uses PremiumGate for the bundled developer guides |
Key files
Entitlement flow
Google Play purchase
-> RevenueCat customer event
-> revenuecat-webhook Edge Function
-> public.subscriptions, provider = play_store
-> EntitlementController.state
-> Home license card, Me row, PremiumGate, guide locksThe differentiator is account-bound entitlement. PurchasesController observes
SessionController.currentUser, calls RevenueCat logIn(userId), and uses the
Supabase Auth UUID as RevenueCat app_user_id. The webhook rejects non-UUID
app_user_id values, so anonymous RevenueCat customers cannot unlock
account-gated surfaces.
EntitlementController is the app authority. It fetches the signed-in user's
subscriptions row, exposes EntitlementState, refreshes after sign-in,
clears on sign-out, refreshes on RevenueCat customer-info updates, and polls
after a purchase while the webhook catches up.
RevenueCat still matters: it provides the Google Play Billing UI, active offering, prices, restore flow, customer-info updates, and management URL. It is not the gate that decides whether Pro content is unlocked.
Product model
The template expects one RevenueCat entitlement:
const val PRO_ENTITLEMENT_ID = "pro"That constant lives in PurchasesController.Companion. Change it there and in
your RevenueCat dashboard if you rename the entitlement.
Recommended Google Play product ids:
| Product id | Store type | Supabase product_type | Access model |
|---|---|---|---|
pro_monthly | Auto-renewing subscription, monthly base plan | month | Entitled while active, trialing, or canceled inside the paid period |
pro_yearly | Auto-renewing subscription, yearly base plan | year | Same rule, yearly period |
pro_lifetime | One-time in-app product | one_time | Entitled indefinitely once active |
The product ids are not hardcoded in the Android app. RevenueCat offerings control the subset, prices, localized labels, and package identifiers returned to the paywall.
Paywall behavior
PaywallViewModel reads the current offering and sorts packages in this order:
- Monthly
- Annual
- Lifetime
- Any other RevenueCat package type
If Annual exists, it is selected by default. Otherwise the first sorted package is selected. The UI renders whatever subset the offering returns, so a build can ship with only monthly/yearly, only lifetime, or all three.
PaywallScreen behavior by state:
| State | User-facing behavior |
|---|---|
| RevenueCat not ready yet | Loading spinner |
| Supabase entitlement already active | Owned-license card |
REVENUECAT_ANDROID_KEY missing | Info card: in-app purchases are unavailable for this build |
| Offering has no packages | Retryable load-error block |
| Packages present | Select package, continue purchase, restore, redeem code, legal links |
Annual packages show a "Most popular" badge and savings when monthly and annual prices make the savings meaningful. Lifetime packages show "Best value".
Gated guides
The in-app Pro example is the developer guide surface:
GuideIndexScreenshows the guide list and lock icons when the user is not entitled.GuideDocumentScreenwraps the document body inPremiumGate.PremiumGateshows content only whenEntitlementState.isEntitled()is true.
Those guides live under the removable Showcase leaf. They are useful as a pattern for your own Pro-only features, but you can delete or replace them when the Showcase tab is removed.
Entitlement rules
Subscription.isEntitled(now) keeps the rules intentionally small:
| Subscription status | Product type | Entitled when |
|---|---|---|
active / trialing | one_time | Always |
active / trialing | month / year | period_end is in the future |
canceled | month / year | period_end is still in the future |
unpaid / expired / unknown | Any | Never |
Unknown enum values decode to Unknown, which locks by default until the app
explicitly supports the new value.
Cross-platform row ownership
public.subscriptions has one row per user. The RevenueCat webhook applies
same-provider Play Store events every time because Play is authoritative for
its own renewals, cancellations, billing issues, and expirations.
When a different provider already owns the row, the incoming event takes over
only if it extends period_end. That means a shorter Play Store subscription
does not shorten a longer Creem or App Store grant, and a shorter sibling
provider event does not shorten an active Play Store period.
Verify
- Add
REVENUECAT_ANDROID_KEY, sign in, and confirm RevenueCat logs in with the Supabase user UUID. - Open the paywall and confirm the active offering's packages render.
- Remove the key, rebuild, and confirm the paywall shows the unavailable info state instead of crashing.
- Make a sandbox purchase and confirm the
subscriptionsrow, not only RevenueCatCustomerInfo, unlocksPremiumGate. - Cancel a subscription in Play testing and confirm access remains until
period_end.
