Billing architecture
Here is how money and plans move through Refract in a way you can trust: recurring subscriptions and optional one-off purchases, the same checkout session pattern, Stripe on the wire, and Postgres as the source of truth once webhooks reconcile. This page is a technical map—everything ties back to real files, and we call out gaps honestly.How it works
This is the fastest mental model: a user action hits GraphQL, which calls the payment processor adapter, and Stripe state is reconciled back into Postgres via webhooks (including downgrade scheduling). Manual quantity updates also sync any pending downgrade schedule in-place:Trial schedule ownership decisions
setOrganizationTrial and cancelOrganizationTrial in apps/backend/src/gql/mutations/ are boundary entrypoints only. The Stripe adapter methods in apps/backend/src/tools/paymentProcessor/stripe/setTrial.ts and apps/backend/src/tools/paymentProcessor/stripe/cancelTrial.ts are the single owners for trial schedule side effects.
The owner path always updates schedules in place instead of release/recreate. Webhook consumers in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/ only reconcile persisted state (trial_requests, subscriptions) and never re-own schedule mutations.
Owner/non-owner responsibilities are explicit:
- Owner: create/update/cancel trial schedules and persist
trial_requests. - Non-owner GraphQL: auth + validation + delegation.
- Non-owner webhooks: reconcile status transitions and complete/cancel rows.
Phased trials: onboarding vs Super Admin retrial
- Org-onboarding SKU: at most one
productsrow may setonboarding_trialJSON (time_unit+time_amount). New org onboarding (apps/backend/src/utilities/subscription.ts→createDefaultSubscription) attaches a phased subscription schedule using that SKU’s duration metadata when eligible. - Super Admin retrial (
setOrganizationTrial): SKU must satisfyphasedTrialEligibleProductand cannot equal the subscription’s current product (trialProductMatchesSubscriptionProduct— seeapps/backend/src/utilities/trials.ts). When the SKU is not the onboarding-designated row, passtrialPhaseTimeUnit+trialPhaseTimeAmount; otherwise onboarding JSON resolves duration automatically (resolveTrialPhaseDurationSeconds). - Stripe is authoritative — if webhook payloads disagree with Postgres, reconciliation updates local trial rows to match Stripe, with drift logged.
Pending downgrade overlap vs nominal post‑trial fallback
This subsection explains why a pending downgrade paired with an active phased trial sometimes rewrites the post‑trial seat (the “fallback”) to the downgrade target—especially when Stripe lines up trial end and subscription item period end on the same instant. How it works When there is a pending downgrade row for the subscription, the helper chooses the fallback product and quantity that downstream trial/schedule builders use after trial:
“Inclusive
≤” is intentional: if cycle end and trial end are equal, overlap still applies. That matches immediate phased trials in Stripe where trial_end and items[0].current_period_end are often the same timestamp while the subscription is trialing. The API’s notion of cycle end follows Stripe’s subscription item, not informal “effective later” copy.
Misunderstandings usually come from copying versus predicates: narrowing to strict < would treat that equality window as nominal in app logic without guaranteeing Stripe exposes a distinct nominal recurring boundary after trial—risking split semantics vs invoices.
Changing this behavior
Extending this system
- Add/extend the payment processor contract in
apps/backend/src/tools/paymentProcessor/stripe/types.ts(example:syncPendingProductDowngradeScheduledQuantity). - Implement the method in the Stripe adapter at
apps/backend/src/tools/paymentProcessor/stripe/(example:syncPendingProductDowngradeScheduledQuantity.tsupdates the schedule in-place and persistschanged_to_quantity). - Call the adapter from the GraphQL boundary in
apps/backend/src/gql/mutations/updateManualQuantity.ts(it fetches the pending downgrade row withlock: transaction.LOCK.UPDATEand then callssyncPendingProductDowngradeScheduledQuantity). - Verify reconciliation in the webhook consumer in
apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/customerSubscriptionUpdated.ts(it readsdowngradeRequest.changed_to_quantityand matches Stripe line items). - Run backend verification:
What not to do
File conventions
1. Overview
High-level summary
Most teams think in subscriptions first—and rightly so: each organization has asubscriptions row tied to a plan Product, and day-to-day feature access still flows from that active plan through product_roles_scopes → scopes.
We also support one-off catalog items (think add-ons, credits, or a fixed bundle sold once). They use the same CheckoutSession preview flow, but settlement updates org-owned quantity instead of swapping subscription lines. Stripe still holds cards and payment state; the backend still reconciles through tools.paymentProcessor and the queued webhook consumer—you never treat Stripe as the entitlement source at request time.
The backend:
- Exposes GraphQL for billing UI flows (preview, checkout session, payment confirmation, downgrades, manual quantity).
- Uses
tools.paymentProcessoras the only gateway to Stripe (create/update subscriptions, payment intents, invoices, products, etc.). - Reconciles Stripe → Postgres via a queued Stripe webhook consumer plus
customer.subscription.updatedfor ongoing sync (quantity, status, cycle dates, scheduled downgrades). - Derives feature access from the org’s active subscription’s product via
product_roles_scopes→scopes(not a separate “entitlements” service)—one-off products are for commerce, not a second parallel RBAC tree.
Product vs Price
A Product is the plan (packaging): name,type (recurring | one_off), scopes, limits, trial metadata, and a thin metadata discriminator (model: seat_based | usage_based for recurring). Entitlements and RBAC always key off subscriptions.product_id, not price.
A Price is the commercial terms: amount, currency, billing interval, quantity rules, usage meter fields, and the processor’s billable line id. Prices live in the prices table with FK → products.id. You can change pricing (new price rows, promote default, retire old prices) without creating a new product.
Subscriptions and checkout sessions reference both:
product_id— which plan the org selected (internalproducts.id)price_id(optional) — which price row bills them; pins grandfathered rates when a price is retired
Resolution: all adapter reads, checkout validation, and webhook line-item matching go through
price.ts. New catalog work should use paymentProcessor.createProduct, which persists products.external_id = prod_* and a default row in prices.
GraphQL exposes the split via Product.prices / Product.default_price → Price. There is no amount/currency column on products — read commercial terms from the price row (or GraphQL fields that resolve from the active default, such as Product.billing_period).
Single currency (no multi-currency migrations)
Refract does not handle multi-currency billing as a first-class product flow today.
Operational guidance: if a customer must move from a plan priced in one currency to another (for example euro to USD), cancel the current subscription first, then let them re-subscribe to the target-currency plan. Do not rely on the normal paid→paid upgrade or scheduled downgrade paths for that change — those assume the commercial currency stays consistent with the environment’s billing currency and the subscription’s existing Stripe prices.
Stripe also requires all prices on a single subscription to share the same currency; mixing currencies on one subscription is unsupported at the processor as well.
See Stripe setup for
STRIPE_DEFAULT_CURRENCY, and Common pitfalls below.
External IDs and swappable processors
Every catalog product stores a Stripe Product id onproducts.external_id (prod_*). Every billable line stores a Stripe Price id on prices.external_id (price_*). Resolve billable lines only through price.ts — never treat Product.external_id as a Stripe Price id.
Checkout and subscriptions:
checkout_sessions.product_id and subscriptions.product_id store the internal products.id. Nullable price_id FKs pin a specific internal prices.id row for grandfathering when a subscription must keep billing on a retired Stripe price.
- New sales / catalog reads: resolve the active default Stripe Price id via
getStripePriceIdForProduct(product)/getActiveDefaultPrice. - Existing subscriptions: resolve via
getActivePriceForSubscription(subscription)— uses pinnedprice_idwhen set (including retired rows), otherwise the product’s active default price row. - Webhooks:
findCatalogRowByStripePriceIdlooks upprices.external_id(including retiredpricesand retired parentproductswithparanoid: false) so Stripeprice_*ids on grandfathered subscriptions still map to catalog rows.
products.retired_at):
- Catalog listings (
publicProducts,billingDetails.availableProducts, non-SAproducts) exclude retired SKUs by default Sequelize paranoid behavior. - Live subscriptions on a retired product still hydrate via includes/lookups with
paranoid: false(productCatalog.ts,getCurrentSubscription, billing/trial/downgrade loaders). - Quantity changes on a subscription already on a retired product remain allowed.
- Upgrade / downgrade / checkout targets must be active (
assertProductActiveForPlanChange). Orgs may leave a retired plan onto an active target. - Super Admin may still list retired products with
showRetired.
prices table):
- The first price created for a product is auto-defaulted.
- At most one active default per product (
is_default = true AND retired_at IS NULL; partial unique index). - Retiring or unsetting the last active default is blocked — promote another price to default first, then retire the old row.
price_id: optional on checkout_sessions; when null, checkout uses the active default at runtime. Upgrade webhooks set subscriptions.price_id from the checked-out product’s active default price row.
- Usage plans: the default
pricesrow is the flat base Stripe price;prices.metadata.externalUsageIdholds a second metered Stripe price id (not the default row).getUsageSubscriptionPriceIdsreturns base + metered when present. - Stripe line-item matching: webhooks and adapter code compare
SubscriptionItem.price.id(viaresolveStripeSubscriptionItemExternalId) to Stripe price ids from the resolution layer, then map back to catalog rows withfindCatalogRowByStripePriceId. - Another processor: the same columns hold that provider’s canonical ids; only adapter implementations are Stripe-specific.
Key architectural decisions
Decoupling from product logic
- Plans are
Productrows (ProductType.RecurringorProductType.OneOff) with thinmetadata({ model: seat_based | usage_based }for recurring,{}for one-off) inapps/backend/src/tools/rds/sequelize/models/product.ts. Commercial terms live on associatedPricerows. - Application features gate on
AllScopesviacanAccessScopes, which resolves scopes from the current org subscription’sproduct_id— not from Stripe objects at request time. - Stripe is confined to
apps/backend/src/tools/paymentProcessor/(and webhook handlers that calltools.paymentProcessor).
Technologies and libraries
- Stripe Node SDK — API version from config (
StripePaymentProcessorType,buildStripePaymentProcessor). - PostgreSQL + Sequelize —
subscriptions,products,prices,checkout_sessions,downgrade_requests,processed_webhooks_events,payment_methods, etc. - Redis — webhook processing lock marker (
webhooks_eventshelper), invoice preview cache, RBAC scope cache (apps/backend/src/tools/cache/webhooksEvents.ts). - Queue abstraction (
tools.queue) —QueueName.STRIPE_WEBHOOKfor webhook payloads; BullMQ by default, swappable via config. - GraphQL (Apollo) — billing queries/mutations under
apps/backend/src/gql/. - Zod — webhook payload validation (
webhookValidators.ts).
2. Architecture
Layering
Interaction with the “functional core”
Refract does not ship a separate package named “functional core.” In practice:- Pure or deterministic helpers live under
apps/backend/src/utilities/(e.g.getQuantityBased,isFreeToPaidUpgrade,hasWebhookEventBeenProcessed). - Side effects are isolated at boundaries: GraphQL resolvers, webhook consumer, and
paymentProcessorimplementations. - Payment processor methods are async I/O; the
StripePaymentProcessorTypeinterface documents the contract (apps/backend/src/tools/paymentProcessor/stripe/types.ts).
Payment processor adapter
- Factory:
buildPaymentProcessorswitches onPaymentProcessorClientType:
- Runtime type:
PaymentProcessorType = Awaited<ReturnType<typeof buildPaymentProcessor>>— the concrete shape is the Stripe implementation (orSTRIPE_MOCKin tests) returned from the factory. - Injection:
ToolsTypeincludespaymentProcessor: PaymentProcessorType.
3. Plans and entitlements
How plans are defined
A plan is aProduct row plus one or more Price rows. See Product vs Price.
On products:
type:ProductType.RecurringorProductType.OneOff.metadata: thin packaging discriminator only:- Recurring seat-based:
{ model: RecurringModel.SeatBased } - Recurring usage-based:
{ model: RecurringModel.UsageBased } - One-off:
{}
- Recurring seat-based:
external_id: Stripe Product id (prod_*).is_default: marks the free / default plan used when creating a default subscription (createDefaultSubscriptionloadswhere: { is_default: true }). The base free plan must also have a zero default price (isBaseFreeDefaultPlan/productCatalog.ts).
prices:
external_id: Stripe Price id (price_*) — the billable subscription line.unit_amount,currency,billing_period,quantity_based: canonical commercial terms.metadata: seatresourceName; usageresourceName,eventName,externalUsageId(metered Stripe price id),pricingBands(seepriceMetadata.ts).is_default: which price row new checkouts use whencheckout_sessions.price_idis null.
isFreeToPaidUpgrade treats the current product as free when it is the base free default plan (is_default + default price unit_amount === 0), and the target’s resolved default price is paid (isFreeToPaidUpgrade).
How entitlements attach to plans
Entitlements are modeled as scopes, attached per product and role viaProductRoleScope (table products_roles_scopes, model productRoleScope.ts).
- Migration for tables:
20250428180842-addScopesAndRoles.ts. getRoleScopesFromProductIdAndRoleloads scopes for a public role; non-public roles receive all scopes (rbac.ts).
Runtime entitlement checks
- Resolver uses
hasScopesmiddleware (hasScopes.ts). canAccessScopes→getScopesForOrganizationMembership:- Resolves active subscription with
getActiveSubscriptionByOrganizationId(ACTIVE_STATUSES: active, trialing, delinquent). - Loads
product_idfrom that subscription. - Reads scopes from
ProductRoleScope(+Scope) or cache.
- Resolves active subscription with
confirmPaymentrequires one ofBILLING_UPGRADE_SUBSCRIPTIONS,BILLING_DOWNGRADE_SUBSCRIPTIONS,BILLING_UPDATE_DETAILS,BILLING_CREATE_DETAILS(ScopeMatchMode.ANY).cancelDowngradeandconfirmDowngraderequireBILLING_DOWNGRADE_SUBSCRIPTIONS(ScopeMatchMode.EVERY, withisLoggedInandpopulateUserWithMembershipsonconfirmDowngrade).cancelOrganizationTrial: OmitorganizationIdto cancel the trial for the current membership organization (portal and org admins).hasScopesenforcesBILLING_DOWNGRADE_SUBSCRIPTIONSfor everyone except super admins (super admins bypass that check). Non-super-admins must not passorganizationId(403 if they do). Super admins may passorganizationIdto target a different organization.
Define a new plan (code / ops)
Preferred: Super Admin Packages UI orpaymentProcessor.createProduct. The adapter creates the Stripe Product + Price(s), sets products.external_id to prod_*, inserts the default row in prices, and stores commercial fields on that price row. Usage-based plans also persist prices.metadata.externalUsageId for the metered Stripe price.
Manual seeds (avoid when possible):
- Create Stripe Product + Price(s); insert
productswithexternal_id=prod_*and thin productmetadata({ model: 'seat_based' }/{ model: 'usage_based' }/{}). - Insert a default
pricesrow withexternal_id=price_*, amount/currency/interval, and any usage metadata. - RBAC: insert rows into
products_roles_scopeslinkingproduct_id,role(PublicRoles), andscope_id. - Cache: saving/destroying
ProductRoleScopeenqueuesSCOPE_CACHE_RESET— no manual Redis flush required for normal CRUD.
Add a new entitlement to an existing plan
- Ensure a
scopesrow exists (uniquename). - Insert
products_roles_scopes(product_id,role,scope_id). - The
ProductRoleScope.afterSave/afterDestroyhooks sendSCOPE_CACHE_RESETsoclearCachedScopesruns.
Product limits (numeric caps)
Scopes answer “can this member do X?” Product limits answer “how many times, if this role is metered?” Policy lives on the product (limit_definitions + product_limits); runtime code calls withProductLimit in apps/backend/src/utilities/productLimits.ts — there is no GraphQL limit middleware.
Bypass, limited, and blocked (per role)
For each limit key, a membership role is in exactly one of these states:
Example: one row “each admin, 10 per month” means admins are limited, members and guests bypass (not metered). To forbid members entirely, use Permissions (scopes), not a missing limit row. To meter members at zero quota, add a member row with
max_value: 0.
Super-admin catalog forms document this in apps/portal/src/pages/superAdmin/pnp/packages/productLimitsFormSection.tsx.
me.memberships.limits exposes maxValue, used, remaining, limitScope (org_wide | per_membership). Super admins are unlimited (empty list).
Enforcement: only withProductLimit({ organizationMember, limitKey, delta, fn, uniqueIdentifier? }). Positive delta checks ceiling; negative delta floors at 0 without max checks. Idempotency: Redis SET NX (7d) + Postgres row lock. If used > max after a downgrade or role change, new consumption is blocked until usage drops or the period resets — usage is not clamped.
Queues: LIMIT_DEFINITION_CACHE_RESET invalidates limits:product:{productId}:v1; LIMIT_USAGE_CYCLE_RESET deletes stale usage rows on billing cycle rollover, org destroy, or member destroy (not role-only updates).
Catalog keys: AllLimitKeys in apps/backend/src/constants/productLimits.ts. Super-admin product create attaches limits inline at create time. Product edit shows limits read-only; caps are not mutable after creation.
📖 See also: .cursor/rules/product-limits.mdc for ownership and anti-patterns.
4. One-off purchases
You already have a clean story for recurring revenue. One-offs exist so you can sell something once—an add-on pack, onboarding fee, or credits—without pretending it is a subscription line item. The app still runs everything through the same preview → checkout session → payment UX; the backend just chooses a different Stripe primitive when the selected product isOneOff.
Recurring checkout vs one-off (same session, different Stripe path)
setCheckoutSession still locks the org’s open row and writes quantity (forced to ≥ 1 for one-off) and checkout_amount_cents from preview—see apps/backend/src/tools/paymentProcessor/stripe/setCheckoutSession.ts.
End-to-end flow (skim this diagram first)
Why the finalizer is strict: Stripe can deliver multiple events for one payment (confirmPayment vs payment_intent.succeeded vs invoice). finalizeOneOffCheckoutForSession checks the Stripe customer matches the org, amount/currency match the session, takes a row lock on checkout_sessions, then bumps org-owned quantity and closes the session—so you cannot double-ship the SKU when two handlers race.
For the full event matrix and queue semantics, read Stripe checkout and webhooks. Super-admins create one-off products from Packages → Billing model → One-off in apps/portal/src/pages/superAdmin/pnp/packages/new/form.tsx.
5. Subscription lifecycle
Common types:New subscription (organization onboarding)
Path A — Default free plan- Organization creation flows call
createDefaultSubscription(subscription.ts). - That loads
Productwithis_default: true, computes quantity viagetQuantityBased, thenpaymentProcessor.createSubscription.
- User selects plan; frontend sets Redux checkout state (
checkoutSlice.ts). invoicePreviewwithchangesresolves quantity and callssetCheckoutSession(invoicePreview.ts).createIntentwithPaymentType.CheckoutValidation: for free-to-paid,handleFreeToPaidUpgradeincreateIntent.tscreates a Stripe subscription (payment_behavior: 'default_incomplete', items from plan + optional usage price).- User completes payment; Stripe emits webhooks.
paymentIntentSucceeded(when metadata type is checkout-related and current product is default) validates invoice/subscription lines against resolved Stripe price ids, updatessubscriptions(external_id,product_id,price_id,quantity, cycle dates), cancels previous external subscription id, closes checkout session (paymentIntentSucceeded.ts).
Upgrade (paid → paid)
invoicePreviewwithchanges(requiresBILLING_UPGRADE_SUBSCRIPTIONS) builds preview viapreviewInvoiceand persists intent viasetCheckoutSession(invoicePreview.ts).createIntentuseshandlePaidToPaidUpgrade: creates a small hold PaymentIntent (CHECKOUT_VALIDATION_AMOUNT_CENTS= 50) inconstants/checkout.ts.confirmPaymentcancels any pendingDowngradeRequest, thenconfirmPaymenton the processor (confirmPayment.ts).payment_intent.amount_capturable_updated:paymentIntentAmountCapturableUpdated:- If no subscription:
createSubscriptionutility (local + Stripe). - If subscription exists:
updateSubscriptionwith old product quantity0and new product quantity fromgetQuantityBased, then updatessubscriptions.product_idandquantity.
- If no subscription:
Downgrade
This flow schedules product downgrades in Stripe and finalizes them when webhooks confirm the new state.-
User selects target plan;
confirmDowngradeGraphQL mutation runs (confirmDowngrade.ts). -
Cancel + create:
confirmDowngradecancels any existing pendingDowngradeRequestrow for the subscription (viapaymentProcessor.cancelDowngradeRequest), then callspaymentProcessor.createDowngradeRequestto create/update the Stripe subscription schedule and write adowngrade_requestsrow (status = pending).- Schedule phase 1 mirrors live Stripe items; phase 2 replaces the full item set with the target product’s price ids from
getUsageSubscriptionPriceIds(seat/base + any metered lines). It must not preserve leftover non-seat lines from the prior plan (e.g. usage → seat).
- Schedule phase 1 mirrors live Stripe items; phase 2 replaces the full item set with the target product’s price ids from
-
Completion:
customerSubscriptionUpdatedrunshandleOpenDowngradeRequests. It completes the row when Stripe’s items match the target plan’s resolved Stripe price id (getStripePriceIdForProduct) and:- for product-changed downgrades, it hydrates
subscription.product_id,subscription.price_id, andsubscription.quantityfrom Stripe - for quantity-only downgrades, it completes when
stripeQuantity === downgradeRequest.changed_to_quantity(customerSubscriptionUpdated.ts).
- for product-changed downgrades, it hydrates
-
Cancel scheduled downgrade:
cancelDowngrade+cancelDowngradeRequest.
Manual seat quantity sync with a pending product downgrade
This section keeps Stripe schedule phase 2 andDowngradeRequest.changed_to_quantity consistent after a manual seat change when a product downgrade is pending.
- Trigger:
updateManualQuantityfinds a pending product downgrade row (downgraded_to_product_id != currentProduct.id). - Current plan update (IMMEDIATE): it resolves the effective current seats with
getQuantityBased(currentProduct, ...), then updates Stripe subscription quantity viapaymentProcessor.updateSubscriptionand persistssubscription.quantity. - Target plan sync: it calls
syncPendingProductDowngradeScheduledQuantity, which resolves the effective target seats viagetQuantityBased(downgradeRequest.downgradedToProduct, ...)before any Stripe/DB writes. - Stripe + DB coupling: the sync helper rebuilds phases and updates the existing schedule in place via
stripe.subscriptionSchedules.update(downgradeRequest.external_id, { phases }), then persistsdowngradeRequest.changed_to_quantity = resolvedSeatQuantity. - Reconciliation (webhook):
customerSubscriptionUpdatedusesdowngradeRequest.changed_to_quantityasscheduledQuantityfor quantity-only completion and hydrates subscription fields from Stripe for product-changed downgrades (customerSubscriptionUpdated.ts).
⚠️ Watch out:DowngradeRequest.changed_to_quantityis the resolved seat count fordowngradedToProduct. It must not be written from the raw UI quantity. The sync helper must callgetQuantityBasedbefore writing Stripe phase 2 orchanged_to_quantity. (syncPendingProductDowngradeScheduledQuantity.ts)
⚠️ Watch out:updateManualQuantityselects the pending downgrade row inside a DB transaction usingtransaction.LOCK.UPDATE. The second call waits for the row lock, and the last committed mutation determines the final schedule rebuild. (updateManualQuantity.ts)
Cancellation
- No dedicated GraphQL “cancel subscription” mutation was found in
apps/backend/src/gql(search forcancelSubscriptiononly hits the payment processor and webhooks). - When Stripe sends
customer.subscription.deleted,customerSubscriptionDeleted:- Sets subscription
statustocancelled. - Calls
createDefaultSubscriptionto attach the org back to the default product (if that succeeds).
- Sets subscription
Renewal
- Not implemented as a separate batch job. Renewals are implicit Stripe billing cycles.
customerSubscriptionUpdatedupdatescycle_start_date/cycle_end_datewhen period bounds change (shouldHydrateSubscriptionCycleDates).
6. Webhook handling
Events handled
FromHandledStripeWebhookEvent:
Payment method ownership
Localpayment_methods rows are created only by payment_method.attached (via syncPaymentMethodFromStripe). payment_intent.succeeded and payment_intent.amount_capturable_updated look up the local row and set the Stripe default through updateOrganization; if the row is missing they return false so Stripe retries after payment_method.attached completes. See apps/backend/src/tools/paymentProcessor/AGENTS.md for the full ownership table.
Invoice document ownership
Invoice document sync (document_type = invoice) is owned by lifecycle webhooks:
invoice.createdcreates initial invoice rows (includingopen).invoice.updatedupdates lifecycle state changes (includingvoid).invoice.paid/invoice.payment_succeeded/invoice_payment.paidstay enabled for checkout finalization flows and as compatibility ingestion paths.
document_type = receipt) remains owned by payment_intent.succeeded and processor confirmPayment one-off finalize paths.
Routing: getHandler.
Receive and verify
- HTTP:
createStripeWebhookHandleronPOST /api/stripe/webhook, registered inindex.tsbeforeexpress.json()withexpress.rawsoreq.bodyis the raw buffer:Stripe.webhooks.constructEventwith that buffer,stripe-signature, and the signing secret from typed config:getWebhookSigningSecretreadstools.configuration.tools.paymentProcessor.webhookSigningSecret(for Stripe, sourced from env such asSTRIPE_WEBHOOK_SECRETinapps/backend/src/configuration/development.ts).- Drops events not in
HandledStripeWebhookEventwith 200 (acknowledged but not queued). hasWebhookEventBeenProcessedearly exit → 200.validateWebhookPayload→ on failure 400 (body:Webhook Error: Invalid payload structure: ...).- On success,
tools.queue.sendToQueueQueueName.STRIPE_WEBHOOK, payloadJSON.stringify(event), respond 200.
Idempotency in the consumer
stripeWebhookConsumer:
- Open DB transaction.
hasWebhookEventBeenProcessed(cache +processed_webhooks_events).cache.helpers.webhooksEvents.setwithnx: true— if key exists, rollback and return (another worker owns the in-flight work).validateWebhookPayload; on failure delete cache key, rollback.- Run handler; if handler returns
false, delete cache key, rollback (event not recorded — Stripe may retry). ProcessedWebhooksEvent.create{ event_id, event_type }, commit.
Same webhook twice
- HTTP: second call sees DB (or cache) processed → 200 without re-enqueueing.
- Queue: second delivery sees processed or fails NX → no double insert of business effects.
- Handler returns
false: row is not written toprocessed_webhooks_events; cache key removed — safe retry on Stripe’s part.
Add a new webhook handler
- Add enum value to
HandledStripeWebhookEvent(stripe.ts). - Add Zod validation branch in
webhookValidators.tsand payload type inwebhookTypes.ts. - Implement handler
(payload, eventId, transaction, tools) => Promise<boolean>. - Register in
getHandlerinindex.ts. - Extend HTTP router check (
Object.values(HandledStripeWebhookEvent)) automatically if enum updated. - Add tests under
__tests__/.
7. Idempotency
Implementation
Why it matters
- Stripe retries webhooks; SQS at-least-once delivery may duplicate.
- Without idempotency: duplicate subscription rows, double plan moves, or inconsistent
downgrade_requests.
Retries
- Safe: Handler returns
false→ noProcessedWebhooksEventrow; Stripe retry re-processes. - Unsafe to assume: Returning
truewithout idempotent business logic — always pair success with durable idempotency keys (DB unique + cache NX pattern used here).
Code reference
8. Billing migrations (operational)
There is no dedicated “subscriber migration” CLI in this repository (no single command that bulk-moves customers between Stripe prices and reconciles DB). Plan changes are implemented through:- Product lifecycle on Stripe + DB:
createProduct,updateProduct,retireProduct,restaureProductonStripePaymentProcessorType. - Per-org upgrades via checkout + webhooks (subscription lifecycle, section 5).
- Downgrade scheduling via
createDowngradeRequest.
Practical guidance (grounded in code behavior)
-
New plan for new customers only
UsecreateProduct(products+ defaultpricesrow). Leave existing subscriptions on their currentproduct_id/ pinnedprice_id. -
Deprecate a plan
UseretireProduct(processor) and hide the plan via theproductsquery (seeapps/backend/src/gql/queries/products.tsfor out-of-stock/in-stock filtering).billingDetailsloads products with Sequelize paranoid defaults (availableProducts = await rds.models.Product.findAll()), so retired SKUs are already excluded there; out-of-stock filtering for the admin catalog still lives on theproductsquery. Not yet implemented: automatic migration of existing Stripe subscriptions to a replacement price without going through checkout/downgrade flows. -
Change pricing without breaking existing subscriptions
Create a newpricesrow (new Stripe Price), promote it to default, retire the old price row. Existing subscriptions keep billing on the old rate via pinnedsubscriptions.price_id. Do not changeproducts.external_id(prod_*) for live plans. -
Grandfathering
Handled by Stripe subscription item price id + DBsubscriptions.price_id(pinned internalprices.id, including retired price rows).
9. Payment processor adapter
Interface definition
The concrete contract isStripePaymentProcessorType (types.ts) — methods include createIntent, confirmPayment, createSubscription, updateSubscription, previewInvoice, createDowngradeRequest, syncPendingProductDowngradeScheduledQuantity, recordUsage, etc.
The app-wide type is PaymentProcessorType from buildPaymentProcessor (inferred).
Stripe implementation
- Entry:
buildStripePaymentProcessorconstructs a realStripeclient fromsecretKey/apiVersion(or injectedconfig.stripefor tests). - Settings: e.g.
quantityDowngradePolicypassed into downgrade/sync helpers.
Swapping Stripe for another provider
Not implemented.buildPaymentProcessor only supports STRIPE and STRIPE_MOCK. Adding another provider requires:
- New
PaymentProcessorClientTypevalue + Zod branch invalidate.ts. - New module implementing the same method surface expected by resolvers/webhooks (or refactor to a narrower shared interface — today the codebase assumes Stripe-shaped flows).
stripeMock (STRIPE_MOCK)
- Config:
PaymentProcessorClientType.STRIPE_MOCK— only{ client: 'stripeMock' }per schema (validate.ts). - Implementation:
createTestStripePaymentProcessorreturns stubbed methods (createIntent→ failure,retrieveInvoice/cancelSubscriptionreject unless tests stub them, etc.).
10. Free plan and upgrade triggers
Free plan
- Identified by
Product.is_default === trueand default priceunit_amount === 0(isBaseFreeDefaultPlan, used byisFreeToPaidUpgrade). createDefaultSubscriptionalways binds new orgs tois_default: trueproduct.
Upgrade prompts (frontend)
- Checkout state:
checkoutSlice.ts—setProductresolves quantity usinggetQuantityBasedfromapps/portal/src/utils/memberships.ts(mirror of server rules). - Server-side preview:
invoicePreviewenforcesBILLING_UPGRADE_SUBSCRIPTIONSwhenform.changesis present (invoicePreview.ts).
Customize triggers
- UI: Gating is standard React routing + feature checks; search frontend for
billingroutes and scope checks (generatedScopeEnuminhooks.tsaligns withAllScopesnames). - API: Add or restrict
AllScopeson roles viaproducts_roles_scopesfor each plan.
11. Extending the billing system
New billing provider
See section 9 — requires factory changes and a full implementation of the operations callers use (createSubscription, updateSubscription, webhooks, etc.).
Usage-based / metered billing
- Product packaging:
ProductType.Recurringwithmetadata.model === RecurringModel.UsageBased(product.ts). - Price commercial terms: default
pricesrow holds flat base amount;prices.metadataholdseventName,externalUsageId,pricingBands,resourceName(priceMetadata.ts). - Reporting usage: call
paymentProcessor.recordUsage— usesstripe.billing.meterEvents.createand incrementsSubscriptionUsageCyclewhen cycle dates exist (recordUsage.ts). - Checkout / subscription items:
createIntentappends meter price items fromgetUsageSubscriptionPriceIdswhen the product is usage-based.
createDowngradeRequest eligibility in createDowngradeRequest.ts and tests.
New plan tier
createProductvia Super Admin or processor API (Stripe Product + Price,products+pricesrows).- Seed
products_roles_scopesfor eachPublicRolesvalue that should differ per tier. - Expose in admin / super-admin GraphQL if using built-in product management mutations.
Customize checkout flow
- Backend: TTL and amounts in
apps/backend/src/constants/checkout.ts(CHECKOUT_SESSION_TTL_MINUTES,CHECKOUT_VALIDATION_AMOUNT_CENTS). - Processor:
setCheckoutSession/getCheckoutSession— see Stripe implementations underapps/backend/src/tools/paymentProcessor/stripe/. - Frontend: Redux checkout slice + billing pages under
apps/portal/src(grepcreateIntent,confirmPayment,invoicePreview).
12. Common pitfalls
Testing without real charges
- Use Stripe test mode keys in config (
development.tspattern). - Use
PaymentProcessorClientType.STRIPE_MOCKfor tests that stub the processor, orbuildStripePaymentProcessorWithMockin processor tests (apps/backend/src/tools/paymentProcessor/__tests__/stripe/_buildProcessorWithMockStripe.ts). - Backend tests run via
make test module=backend(Docker) per project rules.
Known edge cases
- Multiple pending downgrade requests: DB migration
downgrade_requests_one_pending_per_subscription(partial unique onsubscription_idwherestatus = 'pending') enforces at most one pending row per subscription in20250608010922-addDowngradeMarking.ts. - Handler
falsevs thrown error:falsetriggers rollback + cache delete + Stripe retry; thrown error in consumer deletes cache key and rethrows — understand your active queue adapter’s retry and DLQ configuration (BullMQ retry settings by default; SQS visibility timeout if using SQS). - Currency change on an active subscription: not supported in-product. Prefer cancel → re-subscribe on the target currency (Single currency).
Related files (quick index)
What’s next?
If you’re adding a new billing capability, wire it through scopes by readingapps/documentation/architecture/RBAC.md next.
📖 See also: RBAC for howhasScopesresolves permissions from product-role mappings and Redis cache. For phased trial overlap and post‑trial fallback, the rule lives inapps/backend/src/utilities/trials.ts(see Pending downgrade overlap vs nominal post‑trial fallback above).
