All playbooksConsumer Subscription App

Gate a feature behind Premium

Unlock a feature only with an active RevenueCat entitlement, lead free users to the paywall and enforce the same rule in Supabase when the feature has server data.

When to use it

A feature is part of Premium, for example unlimited history or an export. The steps use a PremiumGate widget and, for server data, a premium_notes table as the example.

Rules to keep

  • On the device the entitlement comes only from subscriptionStatusProvider, which reads RevenueCat. Never unlock from a local flag, a cached boolean or a remote config value.
  • An error or a loading state is not Premium: the feature stays locked.
  • The app is not a security boundary. If the feature reads or writes Supabase data, RLS checks subscription_entitlements, which only the revenuecat-webhook Edge Function writes with the service role.
  • Restore stays reachable: free users reach the paywall, which always offers "Restore purchases".

Steps

  1. Create lib/features/subscription/presentation/premium_gate.dart, a ConsumerWidget that shows its child only with an active entitlement:

    return switch (ref.watch(subscriptionStatusProvider)) {
      AsyncData(value: SubscriptionStatus(isPremium: true)) => child,
      AsyncLoading() => Center(
        child: CircularProgressIndicator(semanticsLabel: l10n.loading),
      ),
      // Error or no entitlement: the feature stays locked.
      _ when premiumEnabled => FilledButton(
        onPressed: () => context.pushNamed(AppRoute.premium.name),
        child: Text(l10n.premium),
      ),
      _ => const SizedBox.shrink(),
    };
    

    premiumEnabled is ref.watch(remoteFlagProvider(RemoteFlag.premiumEnabled)): when the flag is off, hide the entry to the paywall as HomePage does; the feature stays locked. Put new texts in the three .arb files.

  2. Wrap the feature with PremiumGate. For messages about trial, grace period or expiry use subscriptionStage() from lib/features/subscription/domain/subscription_lifecycle.dart; it does not unlock anything beyond isPremium.

  3. If the feature only runs on the device, stop here and go to step 6.

  4. For server data add a migration in supabase/migrations/ (append-only). Every policy checks both the owner and the entitlement:

    create policy "premium_notes_select_own" on public.premium_notes for select
      to authenticated using (
        (select auth.uid()) = owner_id
        and exists (
          select 1 from public.subscription_entitlements e
          where e.user_id = (select auth.uid())
            and e.state in ('trial', 'active', 'grace')
            and (e.expires_at is null or e.expires_at > now())
        )
      );
    

    Repeat the condition in the insert, update and delete policies the feature needs (in with check too). Users can read their own entitlement row, so no security definer helper is needed.

  5. Configure the revenuecat-webhook Edge Function as described in docs/subscription-backend.md: without it subscription_entitlements stays empty and the server denies everyone. Webhooks arrive after the purchase (cancellations even hours later), so right after buying the app can show Premium while the server still answers with no rows: show a retry instead of an error.

  6. Test without network. Override subscriptionStatusProvider with Stream.value(...) and check that Premium shows the feature, while free, an error and loading do not. To go through the paywall, reuse the fake repository in test/subscription_test.dart. For server data add a pgTAP test under supabase/tests/database/: insert the entitlement as the test owner, then as authenticated check that a free user gets no rows and a premium one does.

  7. List what Premium includes in PRODUCT.md.

Done when

  • the verification commands pass;
  • the feature is locked when the status is free, failing or loading;
  • server data, if any, is protected by RLS on the entitlement, with a pgTAP test;
  • PRODUCT.md lists the Premium feature.