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 therevenuecat-webhookEdge Function writes with the service role. - Restore stays reachable: free users reach the paywall, which always offers "Restore purchases".
Steps
Create
lib/features/subscription/presentation/premium_gate.dart, aConsumerWidgetthat 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(), };premiumEnabledisref.watch(remoteFlagProvider(RemoteFlag.premiumEnabled)): when the flag is off, hide the entry to the paywall asHomePagedoes; the feature stays locked. Put new texts in the three.arbfiles.Wrap the feature with
PremiumGate. For messages about trial, grace period or expiry usesubscriptionStage()fromlib/features/subscription/domain/subscription_lifecycle.dart; it does not unlock anything beyondisPremium.If the feature only runs on the device, stop here and go to step 6.
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,updateanddeletepolicies the feature needs (inwith checktoo). Users can read their own entitlement row, so nosecurity definerhelper is needed.Configure the
revenuecat-webhookEdge Function as described indocs/subscription-backend.md: without itsubscription_entitlementsstays 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.Test without network. Override
subscriptionStatusProviderwithStream.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 intest/subscription_test.dart. For server data add a pgTAP test undersupabase/tests/database/: insert the entitlement as the test owner, then asauthenticatedcheck that a free user gets no rows and a premium one does.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.mdlists the Premium feature.
