The billing provider knows which plan an organization bought. This package keeps a snapshot of that in entitlementSnapshot and decides what the plan allows. The provider stays the source of truth; the snapshot is a cache of it.
Billing sources
A BillingSource tells the package what an organization has bought. @byo-oss/entitlements exports the type:
// the shape of BillingSource, which @byo-oss/entitlements exports
import type { AuthContext, DBTransactionAdapter } from "better-auth";
import type { BillingState } from "@byo-oss/entitlements";
export interface BillingSource {
readonly id: string;
resolve(organizationId: string, ctx: { adapter: DBTransactionAdapter }): Promise<BillingState | null>;
connect?(context: AuthContext, changed: (organizationId: string) => Promise<void>): void | Promise<void>;
}idis stored on every snapshot. It must be non-empty and not"none", which stands for “no source”. A snapshot whosesourcediffers from the configured source’sidcounts as missing, so switching sources resolves every organization again.resolvereturns the currentBillingState, ornullfor no subscription, and reads throughctx.adapter, so a call inside your transaction stays on it. A throw means “try again later”: reads fall back todefaultPlanwithdegraded: trueand the sweep backs off. There is no permanent-failure type, so a source that can’t answer never drops an organization’s paid plan.connect, if present, is awaited once in the plugin’sinit. It wires the provider’s push: callawait changed(organizationId)after a change.changedresyncs that organization and rejects when the resync failed, so the provider can retry.
BillingState:
| Field | Type | Meaning |
|---|---|---|
planKey |
string | null |
The provider’s plan key, lowercased when stored. A plan or an alias of the catalog |
status |
string | null |
The provider’s status, verbatim: active, trialing, past_due, … |
periodStart, periodEnd |
Date | null, optional |
The subscription period. A metered billing period follows it |
cancelAtPeriodEnd |
boolean, optional | |
seats |
number | null, optional |
Paid quantity, when the provider has one |
sourceRef |
string | null, optional |
The provider’s id for this state, such as a subscription id |
For a provider other than Stripe, see Testing and adapters.
Stripe
fromBetterAuthStripe() from @byo-oss/entitlements/stripe reads the subscription table of @better-auth/stripe and never writes it. Derive that plugin’s plans from the catalog with stripePlans, so the two can’t drift:
import Stripe from "stripe";
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins/organization";
import { stripe } from "@better-auth/stripe";
import { entitlements } from "@byo-oss/entitlements";
import { fromBetterAuthStripe, stripePlans } from "@byo-oss/entitlements/stripe";
import { catalog } from "./catalog";
import { ac, roles } from "./permissions";
export const auth = betterAuth({
plugins: [
organization({ ac, roles }),
stripe({
stripeClient: new Stripe(process.env.STRIPE_SECRET_KEY!),
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
organization: { enabled: true },
subscription: {
enabled: true,
plans: stripePlans(catalog, { pro: { priceId: process.env.STRIPE_PRO_PRICE_ID! } }),
},
}),
entitlements({
catalog,
source: fromBetterAuthStripe(),
staffOrganizationId: process.env.STAFF_ORGANIZATION_ID,
}),
],
});stripePlans(catalog, prices)returns one Stripe plan per key inprices, in catalog order, withnameset to the plan key andlimitsto the catalog’s limits. Those limits are for the Stripe plugin’s own display and are never read back: Stripe holds which plan and status, the catalog holds what the plan allows. A key outside the catalog throws.- The Stripe plugin must be mounted with plans and organizations:
subscription: { enabled: true, plans }andorganization: { enabled: true }. Boot fails when either is off, when the Stripe plugin is missing, or when a Stripe plan name isn’t a catalog plan or alias. A plan withgrouplogs a warning: one live plan per organization. An app that mounts Stripe without plans passes nosource. - Webhooks. The source wraps the Stripe plugin’s
onEventonce. Oncheckout.session.completedandcustomer.subscription.created,.updatedand.deleted, it finds the organization, resyncs it, then calls your own handler. An event for a user, a deleted organization or nothing known resyncs nothing and answers 200. A failed resync throws, so the webhook answers 400 and Stripe redelivers. Create onestripe()plugin perbetterAuth()instance. - Which subscription.
resolvepicks the entitled one: active, then trialing, then past_due, with the latestperiodEndbreaking ties. With none, it keeps the latest ended one, so the snapshot records the last status. - Plugin order against
stripe()is free: the source connects ininit.organization()still comes first. - The source id is
"stripe". PassfromBetterAuthStripe({ id })to store another one on snapshots.
Call refreshEntitlements (or refresh() from the React hook) when the user returns from checkout: the webhook may arrive later.
Entitled statuses and past_due grace
Only an entitled snapshot keeps its billed plan. Every other status, and no subscription, falls back to defaultPlan. By default isEntitled is true for:
activeandtrialing;past_due, forpastDueGraceDays(7 by default) counted frompastDueSince.
pastDueSince is this package’s own timestamp: set when a snapshot first sees past_due, kept while the status stays past_due, cleared otherwise. It doesn’t use Stripe’s period, which can move forward before payment.
Pass isEntitled: (snapshot, now) => boolean to decide yourself. A plan key the catalog doesn’t know answers defaultPlan and logs one warning per process.
Adjusting a plan
resolveEntitlements lets your app adjust what a plan allows per organization, for example paid seats from Stripe’s quantity:
import { betterAuth } from "better-auth";
import { organization } from "better-auth/plugins/organization";
import { entitlements } from "@byo-oss/entitlements";
import { catalog } from "./catalog";
export const auth = betterAuth({
plugins: [
organization(),
entitlements({
catalog,
// paid seats override the plan's member limit
resolveEntitlements: ({ snapshot }) => (snapshot?.seats ? { limits: { members: snapshot.seats } } : {}),
}),
],
});It receives { organizationId, plan, snapshot, features, limits } and returns { features?, limits? }, sync or async. Keys it returns replace the plan’s values. It runs after a plan override and before feature and limit overrides. A hook that throws degrades the read. Call invalidate(orgId) when an app-side input to it changes.
Freshness
The snapshot is kept fresh five ways:
- Push. A source’s events resync one organization at once: the Stripe events above, or
memorySource().setin tests. - Read-through. The first read of an organization with no snapshot resolves from the source and writes one, also when there is no subscription, so the next read is local. Reads inside a transaction resolve without writing.
- Background refresh. A snapshot older than
maxAge(1 hour by default) is served, then refreshed: through better-auth’srunInBackgroundwhenadvanced.backgroundTasks.handleris configured, otherwise by marking it due for the next sweep. - Sweep.
processEntitlementsSyncfrom a cron resyncs due snapshots and backs off on a failing source. See Background work. - Refresh after checkout.
refreshEntitlements,sync(orgId)and the hook’srefresh()bypass the cache and resolve now. Writes the Stripe plugin makes outside its webhook (the/subscription/successredirect, upgrade, cancel) are picked up by the next of these.
Staleness across processes
Each process caches resolved entitlements for up to cacheTtlMs (30 seconds by default, at most 10,000 organizations). A process clears its own entry on its own writes (a sync, an override) and on invalidate(orgId). A write made in another process can show up here up to cacheTtlMs late. Set cacheTtlMs: 0 to read through every time. Usage counters are never cached.
refreshEntitlements returns the latest state. When a concurrent write wins the race, it reads, resolves and writes again, up to 3 more times, then answers 503 ENTITLEMENTS_UNAVAILABLE. A Stripe push that loses the same way answers 400, so Stripe redelivers. sync(orgId) throws in that case.