@happyvertical/smrt-subscriptions
Tenant subscriptions, feature grants, immutable usage evidence, thresholds, effective-dated client pricing, spending policies, and entitlement resolution for s-m-r-t applications.
The package is provider-neutral. Stripe or another billing provider may be recorded as binding metadata, but provider API calls belong in an injected SDK accounting adapter.
Installation
pnpm add @happyvertical/smrt-subscriptions
Add svelte for the optional plan, subscription, and threshold components.
Resolve entitlements
import { SubscriptionResolver } from '@happyvertical/smrt-subscriptions';
const resolver = await SubscriptionResolver.create({ db: 'app.db' });
const context = await resolver.loadEntitlementContext('tenant-1');
const result = await resolver.resolveTenantEntitlements('tenant-1', {
context,
});
if (!result.allowed) {
throw new Error('Subscription thresholds do not allow this operation');
}
console.log(result.planKey, result.featureKeys, result.thresholdEvaluations);
Create one resolver per request or application unit of work and reuse the
loaded EntitlementResolutionContext when several checks need the same
subscription and plan.
Core model
SubscriptionPlanis tenant-scoped by(tenantId, planKey)and stores feature grants, thresholds, and afree,fixed, orpay_what_you_canpricing mode. Amounts (priceAmount,minimumAmount, andsuggestedAmount) are integer minor units. Provider bindings are optional metadata (externalProvider,providerProductId,providerPriceId).TenantSubscriptionbinds a tenant or external subscriber to a plan over an effective period and records its chosenagreedAmountin integer minor units for pay-what-you-can plans. Such subscriptions require an amount at least the plan minimum; free/fixed subscriptions must leave it null. Free and pay-what-you-can plans requirepriceAmount: 0; fixed plans usepriceAmount. Monthly commerce period-close bills tenant subscribers using their agreed amount for pay-what-you-can, never the suggested amount, and skips free plans. External subscriber invoice routing remains host-owned. Pricing mode and minimum are immutable after the plan is saved; create a new plan instead. Other term changes publish a new plan version (see Plan versions). Invalid legacy pricing fails only its payer group at period close and reports the subscription ID while other payer groups continue. Ordinary subscription writes resolve only their own tenant's plans or legacy global plans; trusted system-context billing can link a seller's plan to its child tenant. Entitlement resolution validates plan ownership on both reader and supplied context paths; trusted seller-to-child resolution requires explicit system context too.SubscriptionPlanVersionis an immutable, effective-dated snapshot of a plan's terms; subscriptions pin the version their period paid for.TenantUsageMetricis immutable, idempotent usage evidence.SubscriptionResolvercombines current subscription, plan, and batched usage summaries into one entitlement decision.TenantUsageMeterrecords and summarizes ordinary and AI usage.PricingRule,ClientCharge, andBillingAdjustmentmodel effective-dated commercial evidence.SpendingPolicyEvaluatorapplies scoped budget behavior without embedding a payment provider.PriceBook,PriceBookAssignment,RetailCharge, andCreditGrantadd reseller pricing, the reseller's retail ledger, and prepaid credit (see Reseller billing).
Subscribers are polymorphic: tenant subscribers use a tenant ID, while external
subscribers add a stable external discriminator. Utilities such as
normalizeSubscriber() and assertSubscriberInvariant() keep that identity
coherent.
Upgrade note
SubscriptionPlanCollection.findByPlanKey now requires (tenantId, planKey);
pass null explicitly for legacy global plans. Update callers from the old
single-argument signature. Pricing mode and minimum cannot be edited after
persistence, even before the first subscriber, so a concurrent signup cannot
observe terms that change mode underneath it. Existing fixed-price edits and
current-period entitlement versioning arrived with #4032 (see
Plan versions). Concurrent first creates
for one (tenantId, planKey) use a strict insert: one succeeds and a collision
fails without changing the winner. Retry by reading the existing plan; a
sequential create with matching mode/minimum retains natural-key upsert behavior.
Retrying different pricing mode/minimum is rejected. The provider-neutral price
lookup falls back to legacy Stripe IDs when the binding is Stripe or unspecified;
explicit bindings to other providers do not use that fallback. PlanPicker accepts
optional pricing mode, minimum and suggested amounts, labeling free plans and
PWYC terms explicitly instead of displaying them as zero fixed-price plans.
Existing databases created before #3829 may retain the global
_smrt_subscription_plans_plan_key_idx on older migration runners. Run
scripts/migrate-3829-drop-legacy-plan-key-index.ts after the ordinary manifest
migration so different tenants can reuse a plan key. The script supports SQLite
and PostgreSQL and refuses DuckDB, whose inline unique constraint needs a
reviewed table rebuild.
Plan versions and paid periods
A SubscriptionPlan is the stable tier identity, scoped by
(tenantId, planKey). Its terms — status, priceAmount, currency,
billingInterval, pricingMode, minimumAmount, suggestedAmount,
feature grants and thresholds — are published as immutable
SubscriptionPlanVersion rows (#4032):
- Saving a plan whose terms differ from its latest version publishes the next
version (1, 2, 3 …), effective now.
plan.publishTerms({ effectiveFrom })schedules a future amendment instead. Versions are append-only (no update, no delete) and monotonic: a version cannot start before the previous one. Re-saving unchanged terms, or changing onlyname/description/metadata, publishes nothing. - A version is in force from its
effectiveFromuntil the next version's; an instant before the first version resolves to version 1. TenantSubscription.planVersionIdis the version frozen for the current period (planVersionPinnedAt,planVersionPinReason, andplanVersionPinNoteaudit it). It is maintained on every save:- a new subscription pins the version in force at its period start
(
currentPeriodStart ?? startedAt); - renewal — a save whose new
currentPeriodStartis at or after the storedcurrentPeriodEnd(or, for an open-ended row, after the stored start) — re-pins the version in force at the new boundary, for the plan the row now names. A downgrade or a plan change that removes features is applied this way. Moving the start within a paid period is not a renewal, and a renewal saved before its start date may change the pinned terms only as an upgrade (otherwise save it once the period has started). It pins the version scheduled for its boundary at save time; an amendment published later but effective before that boundary applies from the following renewal (or re-save the renewal after the period starts). Period dates are host/provider-owned: shorteningcurrentPeriodEndends the paid period; - at creation and renewal the boundary decides the version: naming any
other
planVersionId(for example to grandfather an import) needs an authorized replacement; - within an unchanged period, a different plan or version is accepted only
when it is an upgrade (
isEntitlementUpgrade(): every enabled feature stays enabled and no threshold is added, lowered or made stricter), or when the period no longer entitles. Anything else is rejected; subscription.replacePlanVersion({ policy: 'authorized', reason })is the explicit, audited escape hatch for an approved in-period downgrade or correction.policy: 'upgrade'applies an amended version of the same plan mid-period when it only adds.
- a new subscription pins the version in force at its period start
(
- Cancellation keeps the paid period: a subscription canceled while it
entitled (
active/trialingwithin its period) recordsretainsPaidPeriodand entitles untilcurrentPeriodEnd, then resolves nothing. A subscription canceled fromincomplete,past_dueorunpaid(for example a provider'sincomplete_expired) entitles nothing. For an immediate cancellation of a paid period (e.g. with a refund) setcurrentPeriodEndto the cancellation time.
SubscriptionResolver resolves entitlements at options.now from the pinned
version, not the live plan: editing, archiving, or removing a feature from a
plan never changes a paid period. A pinned version that cannot be found, or
whose status is not active, fails closed. EntitlementResolution reports
planVersionId and planVersion; EntitlementResolutionContext carries
planVersion, validated against the subscription's plan, pin and tenant like
plan. Resolvers built from custom readers without planVersions keep the
live-plan behavior.
// A co-op removes a perk. Paying members keep it until their renewal.
plan.setFeatureGrants(['coop:vote']);
await plan.save(); // publishes version 2, effective now
// Schedule an entitlement change from a future date.
plan.setFeatureGrants(['coop:vote', 'coop:shift-swap']);
await plan.publishTerms({ effectiveFrom: new Date('2027-01-01T00:00:00Z') });
// Resolve what a member is entitled to at an instant.
const result = await resolver.resolveEntitlements(subscriber, { now });
result.planVersion; // the version their current period paid for
The plan row always shows the latest published terms, including a scheduled version's. Hosts that charge from the plan row must price from the pinned version instead; commerce period-close still reads the live row until #4146, so do not schedule a price change ahead of time while relying on it.
The plan row is written before its version. If publishing fails, the save
rejects with the row already updated and the next save publishes the missing
version; wrap the save in plan.withTransaction() when both writes must be
atomic.
Migrating existing data
- Run the ordinary manifest migration (
smrt db:migrate). It adds the_smrt_subscription_plan_versionstable and the nullableplan_version_id,plan_version_pinned_at,plan_version_pin_reasonandplan_version_pin_noteandretains_paid_periodcolumns on_smrt_tenant_subscriptions. Nothing is dropped or rewritten. Existingcanceledrows default toretains_paid_period = false, so they stay unentitled exactly as before. - Run
migrateSubscriptionPlanVersions(db)once. Every plan without versions gets version 1 — its current terms, effective from the plan's creation — and every unpinned subscription is pinned to it withplanVersionPinReason: 'migration'. Entitlements are unchanged. The run is idempotent, works across tenants in system context, and reports any subscription it could not re-save infailed(that row keeps resolving through version 1).
Until step 2, unpinned subscriptions to plans without versions keep resolving from the live plan, and the first edit of such a plan records its pre-edit terms as version 1 before publishing the change.
Thresholds and spending
Thresholds define a metric key, time window, limit, and enforcement behavior. The resolver batches metric reads when possible. Do not bypass tenant context or repeatedly construct a new resolver inside one request.
Commercial usage records client-facing prices separately from usage evidence. Spending policy decisions can allow, warn, require approval, or deny based on the configured behavior and scope.
Reseller billing
Resellers buy a service at wholesale and resell it to their own child tenants.
Parentage and the billing owner come from smrt-tenancy's
BillingRelationshipService; this package prices usage against it.
-
Price books. A
PriceBookis published by one seller tenant and is eitherwholesale(what a provider charges a reseller) orretail(what a reseller charges its children). Its prices are ordinaryPricingRulerows carrying the book'spriceBookId, one row per currency. -
Assignment per relationship.
ResellerBillingService.assignPriceBooks()picks, for one child, the wholesale book and currency and (optionally) the reseller's retail book and currency. An assignment made under one reseller never applies after the child moves to another. -
Rating.
CommercialUsageService.rateUsage()resolves the child's relationship and returns aUsageRating:- self-billed or unrelated tenant: one direct
ClientCharge, priced exactly likeprice(); - reseller-billed: a wholesale
ClientChargepayable by the reseller (tenantId= reseller,usageTenantId= child) and, with a retail book, aRetailChargethe child owes the reseller, committed together.
A usage event is rated once.
ClientChargekeeps exactly one row per usage event, so a later billing-owner change returns the original rating instead of charging a second payer. - self-billed or unrelated tenant: one direct
-
Delegated spending.
setDelegatedSpendingPolicy()writes aSpendingPolicyon the child withsetByTenantId= the parent. The ordinarySpendingPolicyEvaluatorenforces it (observe, warn, approval, block), and the child cannot edit, delete, or overwrite it. Parents change or retire (active: false) their policies through the same method. A policy'sbasisselects what it counts:billed(what the tenant pays the provider, the default),retail(what it owes its reseller), orwholesale(what the parent pays for this child's usage). -
Prepaid credit. A
period: 'balance'policy's limit is the sum of itsCreditGrants; spend accumulates frombalanceFrom. Its currency and period cannot change once saved; create a new balance instead. Grants are append-only (updates and deletes are refused) and idempotent persource/sourceId. PassautoTopUptoSpendingPolicyEvaluator.create()to be called when a charge would exhaust a balance; return a grant once the host has secured the funds. The evaluator never calls a payment provider. Before charging, a hook can call the request'scurrentShortfall()to recompute the shortfall against the current balance (another evaluation may have topped it up meanwhile); the evaluator re-reads the balance after the hook returns either way. smrt-commerce'sBillingRuntime.autoTopUpHook()charges a saved card.
import { BillingRelationshipService } from '@happyvertical/smrt-tenancy';
import {
CommercialUsageService,
ResellerBillingService,
} from '@happyvertical/smrt-subscriptions';
const relationships = await BillingRelationshipService.create({
db,
tenantExists,
});
const reseller = await ResellerBillingService.create({
db,
billingRelationships: relationships,
});
await reseller.definePrice({
priceBookId: retailBook.id,
ruleKey: 'tokens',
metricKey: 'ai.tokens',
strategy: 'fixed_unit',
prices: [
{ currency: 'USD', terms: { unitPrice: 5 } },
{ currency: 'EUR', terms: { unitPrice: 4.6 } },
],
});
await reseller.assignPriceBooks({
childTenantId,
wholesale: { priceBookId: wholesaleBook.id, currency: 'USD' },
retail: { priceBookId: retailBook.id, currency: 'EUR' },
});
const rating = await commercial.rateUsage({
usageEventId,
billingRelationships: relationships,
approved: true,
});
Mutations require a system context, a super-admin bypass, or a host
authorize callback, like BillingRelationshipService; a seller may also
define prices in its own book under its own tenant context. The callback
receives the tenant whose authority the action exercises: the book's publisher
for define_prices and assign_wholesale_price_book, the parent for
assign_retail_price_book and manage_child_spending. Bind every decision to
the caller, and never authorize a reseller for the wholesale leg that charges
it. The package refuses a wholesale book published by the reseller or child it
would charge, but whether a publisher is a legitimate provider for that
reseller (resellerTenantId in the request) is the host's decision. The
calls first read the child's relationship through BillingRelationshipService
in the caller's context, so a provider assigning a wholesale leg also needs
that service's authorize to grant it read for the child. Once
authorized, the service writes in a system context, because a parent's writes
land on rows the child owns.
const reseller = await ResellerBillingService.create({
db,
billingRelationships: relationships,
authorize: async ({ action, tenantId, resellerTenantId }) => {
const caller = currentTenantId();
if (caller !== tenantId || !(await isBillingAdmin(caller))) return false;
return action === 'assign_wholesale_price_book'
? isProviderFor(caller, resellerTenantId)
: true;
},
});
Rating reads a seller's books across tenants, so run it where the host's
tenancy rules allow that (typically a system context). Spending evaluation can
run in the child's own tenant context: a wholesale-basis policy reads only
the sum of the parent's charges for that child's usage. Pass
billingRelationships to SpendingPolicyEvaluator.create() so a former
parent's delegated policies stop applying after the child changes reseller;
without it they keep applying (fail closed), and the new parent may replace
them by name. A delegated balance counts only credit granted by its current
parent. A former parent's prepaid balance cannot be taken over by name; it
keeps its credit ledger (reversible in a system context), and the new parent
creates its own balance. A parent's retail cap counts only what the child owes that
parent.
Svelte entry point
@happyvertical/smrt-subscriptions/svelte exports provider-neutral,
presentational components:
PlanPickerSubscriptionSummaryUsageThresholdsCommercialOverview
Hosts own data loading and mutation actions.
Development
pnpm --filter @happyvertical/smrt-subscriptions test
pnpm --filter @happyvertical/smrt-subscriptions test:postgres
pnpm --filter @happyvertical/smrt-subscriptions typecheck
pnpm --filter @happyvertical/smrt-subscriptions build
See AGENTS.md for provider, threshold, and resolver guidance.