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:
| Tool | Use it to |
|---|---|
*appIfEntitled | Show part of a page only to paying members, optionally from a minimum tier rank. |
entitledGuard | Open a whole route only for paying members. |
EntitlementService | Read 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
Set up Payments.
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.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
| Field | Meaning |
|---|---|
isPro | true while the person holds a paid plan. Missing or false: a free user. |
premiumType | The plan's key, such as gold. null when not a member. |
premiumTierRank | The plan's rank. null when not a member. |
premiumStatus | active, trialing, past_due, cancelled or expired. |
premiumExpiresAt | Subscriptions: the end of the paid period or trial. Absent for one-time purchases, which never end. |
updatesUntil | One-time purchases: the end of the free-updates window. It never ends access. |
creditBalance | Prepaid credits left. |
provider, providerSubscriptionId, providerCustomerId | The payment provider (dodo) and its ids, for support. |
premiumTierLabel, premiumDiscountCode | The pricing tier and discount code the person bought at. |
How the plan changes
- One plan at a time. A purchase replaces the current plan only when its rank is the same or higher; buying a lower plan while holding a higher one leaves the higher one.
- A failed renewal sets
premiumStatustopast_dueand keepsisProtrue. - Cancelled, expired or fully refunded:
isProbecomesfalse,premiumTypeandpremiumTierRankbecomenull, andpremiumStatusbecomescancelledorexpired(a full refund setsexpired). - A missed webhook: every morning a subscription whose
premiumExpiresAtis more than 3 days past is set toexpired. - The plan is not in the ID token. Arc CMS puts no plan claims on accounts, so always read the record.
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
- Your own mapping of plans to features belongs in your app's code, as in the example above.
- Security rules for app data by plan: the plan is not a claim, so a rule in
firestore.app.ruleshas to read the record, which costs one document read per check:
See Set security rules and indexes.function hasPaidPlan() { return isSignedIn() && get(/databases/$(database)/documents/users/$(request.auth.token.get('arccms_uid', ''))).data.get('isPro', false) == true; } - Your own Cloud Functions read the record with the Admin SDK: find it by
uid, or by thearccms_uidclaim of the caller.
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
| Action | Who |
|---|---|
| Read a record's plan fields | The person themselves, and admins |
| Change the plan or credit fields | Cloud Functions. The rules refuse a person changing their own; admins are not blocked by the rules, but no admin screen edits them. |
Read CreditLedger entries | The person they belong to, and admins |
| Spend credits | The signed-in person, through consumeCredits |
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Paid, but isPro is still false | The webhook has not been processed. See Payments troubleshooting. |
| Bought a lower plan and nothing changed | A higher-ranked plan is still active; the higher one wins. |
| A page still shows locked content after purchase | EntitlementService reads once. Call load().subscribe() again, or reopen the page. |
| A plan is unknown to your app | Someone 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 record | It is reading a different record, or a different Firestore database. A person can read only their own record. |