Premium entitlements

Show or lock a page, a part of a page, or an app feature by what the signed-in person has paid for. The plan lives on the person's own record, written only by the server.

What it is

When a payment succeeds, the payment functions write the buyer's plan onto their users record: whether they are a paying member (isPro), which plan (premiumType), its rank, its status and when it ends. Arc CMS records the plan; your pages and your app decide what each plan unlocks.

Arc CMS gives you three ready-made checks for its own pages and for pages in the custom space:

ToolUse it to
*appIfEntitledShow part of a page only to paying members, optionally from a minimum tier rank.
entitledGuardOpen a whole route only for paying members.
EntitlementServiceRead the plan and credit balance as signals in your own code.

Another codebase that shares the Firebase project reads the same fields straight from Firestore (below).

Turn it on or off

Entitlements are part of the Payments feature (id payments) and are there whenever payments is on. They have no switch of their own.

With payments off, nothing writes the plan, /user/premium shows the not-found page, and the member area does not load the plan. The directive and the guard still compile, but no purchase can grant a plan any more.

With payments off, a route of yours behind entitledGuard sends every signed-in person without a plan to /pricing, which is then a not-found page. Remove the guard, or keep payments on.

Set it up

  1. Set up Payments.

  2. Decide your plans and give each one a Premium type (a short key such as plus, gold, platinum) and a Tier rank (a number, higher means more access). Enter them on each product under Products.

  3. Write down which features each plan unlocks, in one place in your app's code. Arc CMS does not know your features: the premium type strings and rank numbers are the contract between the products an admin sets up and your code, so agree them before anyone edits a product.

Use it

The fields on the record

FieldMeaning
isProtrue while the person holds a paid plan. Missing or false: a free user.
premiumTypeThe plan's key, such as gold. null when not a member.
premiumTierRankThe plan's rank. null when not a member.
premiumStatusactive, trialing, past_due, cancelled or expired.
premiumExpiresAtSubscriptions: the end of the paid period or trial. Absent for one-time purchases, which never end.
updatesUntilOne-time purchases: the end of the free-updates window. It never ends access.
creditBalancePrepaid credits left.
provider, providerSubscriptionId, providerCustomerIdThe payment provider (dodo) and its ids, for support.
premiumTierLabel, premiumDiscountCodeThe pricing tier and discount code the person bought at.

How the plan changes

Lock part of a page

Import IfEntitledDirective from src/app/pages/user/if-entitled.directive.ts into your component:

<div *appIfEntitled>Any paying member sees this.</div>
<div *appIfEntitled="2">Rank 2 and above see this.</div>

The directive shows what EntitlementService has loaded. The member area's shell and entitledGuard load it; on any other page, call inject(EntitlementService).load().subscribe() first, or the content stays hidden.

Lock a route

In src/custom/routes.ts, put userGuard and entitledGuard from src/app/pages/user/user.guards.ts on the route:

{
    path: 'lessons/advanced',
    canActivate: [userGuard, entitledGuard],
    loadComponent: () => import('./lessons/advanced.component').then((m) => m.default),
}

A visitor who is not signed in goes to /signup and comes back after signing in; a signed-in person without a plan goes to /pricing. /user/premium is Arc CMS's own example of such a page. See Add pages and routes.

Read the plan in code

EntitlementService (src/app/pages/user/entitlement.service.ts) has the signals isPro(), premiumType(), premiumStatus(), tierRank(), creditBalance() and entitlement() (the whole record), and hasTier(minRank). It reads the record once each time load() is subscribed to: the member area's shell and entitledGuard do it, and you call load().subscribe() again after something changes, such as spending credits.

The directive, the guard and hasTier() check isPro and the rank only. A past_due subscription still passes until it expires. If a feature must stop at the first failed payment, check premiumStatus() as well.

Read the plan from another app

An app in another codebase that shares the Firebase project reads the person's record with the Firestore SDK. The record id is the arccms_uid claim in their ID token (see Follow the account contract); use the same Firestore database as Arc CMS (see Sharing a Firebase project). Listen, so access changes the moment a payment lands:

import { doc, onSnapshot } from 'firebase/firestore';

const token = await auth.currentUser.getIdTokenResult();
const userDocId = token.claims['arccms_uid'];

onSnapshot(doc(db, 'users', userDocId), (snap) => {
  applyPlan(snap.data()); // work out what is unlocked
});

Then decide, and fail closed: when anything is missing or unknown, lock.

const TIERS = { plus: 1, gold: 2, platinum: 3 };   // your own mapping

function isUnlocked(user, minRank) {
  if (!user || user.isPro !== true) return false;
  if (user.premiumStatus !== 'active' && user.premiumStatus !== 'trialing') return false;
  if (user.premiumExpiresAt && Date.now() > user.premiumExpiresAt.toMillis()) return false;
  return (TIERS[user.premiumType] ?? 0) >= minRank;
}

Right after checkout, allow a few seconds for the webhook: show "finishing your purchase" and let the listener unlock when the fields change. Never unlock from the checkout return URL.

Credits

A person's balance is creditBalance on their record, and every change is a CreditLedger entry they can read (userId is their Auth uid). Spend credits with the consumeCredits callable, described in Payments.

Extend it

What it stores

The fields above, on users/{docId}, plus a few the functions keep for themselves: premiumEventAt (the last event applied, so late events cannot undo newer ones), premiumTrialReminderSent and updatesEndingReminderSent. Credit changes go to CreditLedger. They are written by the payment functions (handlePaymentEvent, consumeCredits, scanExpiredEntitlements, scanTrialEndings, scanUpdatesEnding); see Payments and Data model.

Who can do what

ActionWho
Read a record's plan fieldsThe person themselves, and admins
Change the plan or credit fieldsCloud Functions. The rules refuse a person changing their own; admins are not blocked by the rules, but no admin screen edits them.
Read CreditLedger entriesThe person they belong to, and admins
Spend creditsThe signed-in person, through consumeCredits

Troubleshooting

SymptomCause and fix
Paid, but isPro is still falseThe webhook has not been processed. See Payments troubleshooting.
Bought a lower plan and nothing changedA higher-ranked plan is still active; the higher one wins.
A page still shows locked content after purchaseEntitlementService reads once. Call load().subscribe() again, or reopen the page.
A plan is unknown to your appSomeone typed a different premium type on a product. Treat unknown types as the lowest rank and fix the product.
Another app gets "permission denied" reading the recordIt is reading a different record, or a different Firestore database. A person can read only their own record.