Skip to main content

Billing architecture

Technical reference for the Refract billing stack: Stripe-backed subscriptions, checkout, webhooks, RBAC entitlements, and operational patterns. Every behavior described here is grounded in the repository; gaps are called out explicitly.

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:
await paymentProcessor.syncPendingProductDowngradeScheduledQuantity(
  {
    subscription: currentSubscription,
    downgradeRequest: pendingProductDowngrade,
    organization: organizationEntity,
    requestedSeatQuantity: quantity,
    transaction,
  },
  tools,
);

Extending this system

  1. Add/extend the payment processor contract in apps/backend/src/tools/paymentProcessor/stripe/types.ts (example: syncPendingProductDowngradeScheduledQuantity).
  2. Implement the method in the Stripe adapter at apps/backend/src/tools/paymentProcessor/stripe/ (example: syncPendingProductDowngradeScheduledQuantity.ts updates the schedule in-place and persists changed_to_quantity).
  3. Call the adapter from the GraphQL boundary in apps/backend/src/gql/mutations/updateManualQuantity.ts (it fetches the pending downgrade row with lock: transaction.LOCK.UPDATE and then calls syncPendingProductDowngradeScheduledQuantity).
  4. Verify reconciliation in the webhook consumer in apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/customerSubscriptionUpdated.ts (it reads downgradeRequest.changed_to_quantity and matches Stripe line items).
  5. Run backend verification:
make test module=backend

What not to do

File conventions

1. Overview

High-level summary

Billing is subscription-centric: each organization has a row in subscriptions linked to a products row (plan). Stripe holds payment instruments, subscription state, and invoices. The backend:
  • Exposes GraphQL for billing UI flows (preview, checkout session, payment confirmation, downgrades, manual quantity).
  • Uses tools.paymentProcessor as 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.updated for ongoing sync (quantity, status, cycle dates, scheduled downgrades).
  • Derives feature access from the org’s active subscription’s product via product_roles_scopesscopes (not a separate “entitlements” service).

External IDs and swappable processors

  • Product.external_id (and related fields such as usage metadata.externalUsageId where applicable) are processor-facing identifiers: they link a Refract Product to whatever the active payment processor uses for that plan’s billable subscription line (and meters/usage, if any).
  • Stripe today: the adapter stores the Price id (price_*) in Product.external_id and matches webhook/API line items via resolveStripeSubscriptionItemExternalId against SubscriptionItem.price.id (with a legacy plan.id fallback).
  • Another processor: the same column holds that provider’s canonical id for the equivalent line item; webhook and paymentProcessor code for that adapter must compare against the same contract. The domain model stays processor-agnostic; only adapter implementations are Stripe-specific.

Key architectural decisions

DecisionRationale (as reflected in code)
Product.external_id is the processor’s id for the plan’s primary lineNot inherently “a Stripe field”: for Stripe it is a Price id; matching logic lives in the Stripe adapter and helpers (e.g. resolveStripeSubscriptionItemExternalId).
Checkout uses a DB CheckoutSession + small validation PaymentIntent (paid-to-paid) or subscription create (free-to-paid)Paid-to-paid: createIntent creates a hold PI; paymentIntentAmountCapturableUpdated applies the plan change. Free-to-paid: subscription created incomplete, completed via paymentIntentSucceeded.
Webhooks enqueued after HTTP verifyapps/backend/src/routers/api/stripe.ts verifies signature, dedupes, validates payload, then sendToQueue — work is async and idempotent in the consumer.
Idempotency: Redis NX + processed_webhooks_events + transactionPrevents double application of the same Stripe event.id (stripeWebhookConsumer, hasWebhookEventBeenProcessed).
Scopes cached per (productId, role)getScopesForOrganizationMembership uses Redis; ProductRoleScope hooks enqueue SCOPE_CACHE_RESET (scopeCacheResetConsumer).

Decoupling from product logic

  • Plans are Product rows with typed metadata (seat-based vs usage-based) in apps/backend/src/tools/rds/sequelize/models/product.ts.
  • Application features gate on AllScopes via canAccessScopes, which resolves scopes from the current org subscription’s product_id — not from Stripe objects at request time.
  • Stripe is confined to apps/backend/src/tools/paymentProcessor/ (and webhook handlers that call tools.paymentProcessor).

Technologies and libraries

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 paymentProcessor implementations.
  • Payment processor methods are async I/O; the StripePaymentProcessorType interface documents the contract (apps/backend/src/tools/paymentProcessor/stripe/types.ts).

Payment processor adapter

export enum PaymentProcessorClientType {
  STRIPE = 'stripe',
  STRIPE_MOCK = 'stripeMock',
}
  • Runtime type: PaymentProcessorType = Awaited<ReturnType<typeof buildPaymentProcessor>> — the concrete shape is the Stripe implementation (or STRIPE_MOCK in tests) returned from the factory.
  • Injection: ToolsType includes paymentProcessor: PaymentProcessorType.

3. Plans and entitlements

How plans are defined

A plan is a Product row:
  • external_id: Payment processor identifier for this plan’s primary subscription line (Stripe adapter: Price id price_* — see External IDs and swappable processors).
  • metadata: discriminated by type:
    • ProductType.SeatBased: quantityBased (admin | allMembers | manual | none), resourceName.
    • ProductType.UsageBased: externalUsageId, eventName, pricingBands, etc. (see Zod schemas in product.ts).
  • is_default: marks the free / default plan used when creating a default subscription (createDefaultSubscription loads where: { is_default: true }).
  • price: amount in smallest currency unit; isFreeToPaidUpgrade treats price === 0 or is_default === true as “free” side (isFreeToPaidUpgrade).

How entitlements attach to plans

Entitlements are modeled as scopes, attached per product and role via ProductRoleScope (table products_roles_scopes, model productRoleScope.ts).

Runtime entitlement checks

  1. Resolver uses hasScopes middleware (hasScopes.ts).
  2. canAccessScopesgetScopesForOrganizationMembership:
    • Resolves active subscription with getActiveSubscriptionByOrganizationId (ACTIVE_STATUSES: active, trialing, delinquent).
    • Loads product_id from that subscription.
    • Reads scopes from ProductRoleScope (+ Scope) or cache.
Examples:
  • confirmPayment requires one of BILLING_UPGRADE_SUBSCRIPTIONS, BILLING_DOWNGRADE_SUBSCRIPTIONS, BILLING_UPDATE_DETAILS, BILLING_CREATE_DETAILS (ScopeMatchMode.ANY).
  • cancelDowngrade and confirmDowngrade require BILLING_DOWNGRADE_SUBSCRIPTIONS (ScopeMatchMode.EVERY, with isLoggedIn and populateUserWithMemberships on confirmDowngrade).

Define a new plan (code / ops)

  1. Stripe (current adapter): create Stripe Product + Price(s); set Product.external_id to the Price id (price_*). For usage-based plans, also set metadata.externalUsageId to the processor’s usage/meter price id as created by your Stripe flows.
  2. Database: insert into products with valid metadata JSON matching Zod (zodSeatBasedProductMetadata or zodUsageBasedProductMetadata).
  3. RBAC: insert rows into products_roles_scopes linking product_id, role (PublicRoles), and scope_id.
  4. Cache: saving/destroying ProductRoleScope enqueues SCOPE_CACHE_RESET — no manual Redis flush required for normal CRUD.
Example metadata shape (seat-based), from model definitions:
// Seat-based (conceptual; actual insert via Sequelize migration/seed/admin UI)
{
  "type": "seatBased",
  "quantityBased": "manual",
  "resourceName": "seat"
}

Add a new entitlement to an existing plan

  1. Ensure a scopes row exists (unique name).
  2. Insert products_roles_scopes (product_id, role, scope_id).
  3. The ProductRoleScope.afterSave / afterDestroy hooks send SCOPE_CACHE_RESET so clearCachedScopes runs.

4. Subscription lifecycle

Common types:
// apps/backend/src/tools/rds/sequelize/models/subscription.ts
export enum SubscriptionStatus {
  Active = 'active',
  Trialing = 'trialing',
  Delinquent = 'delinquent',
  Cancelled = 'cancelled',
}

export const ACTIVE_STATUSES = [
  SubscriptionStatus.Active,
  SubscriptionStatus.Trialing,
  SubscriptionStatus.Delinquent,
];

New subscription (organization onboarding)

Path A — Default free plan
  1. Organization creation flows call createDefaultSubscription (subscription.ts).
  2. That loads Product with is_default: true, computes quantity via getQuantityBased, then paymentProcessor.createSubscription.
Path B — Paid checkout (free → paid)
  1. User selects plan; frontend sets Redux checkout state (checkoutSlice.ts).
  2. invoicePreview with changes resolves quantity and calls setCheckoutSession (invoicePreview.ts).
  3. createIntent with PaymentType.CheckoutValidation: for free-to-paid, handleFreeToPaidUpgrade in createIntent.ts creates a Stripe subscription (payment_behavior: 'default_incomplete', items from plan + optional usage price).
  4. User completes payment; Stripe emits webhooks.
  5. paymentIntentSucceeded (when metadata type is checkout-related and current product is default) validates invoice/subscription lines vs checkout session, updates subscriptions (external_id, product_id, quantity, cycle dates), cancels previous external subscription id, closes checkout session (paymentIntentSucceeded.ts).

Upgrade (paid → paid)

  1. invoicePreview with changes (requires BILLING_UPGRADE_SUBSCRIPTIONS) builds preview via previewInvoice and persists intent via setCheckoutSession (invoicePreview.ts).
  2. createIntent uses handlePaidToPaidUpgrade: creates a small hold PaymentIntent (CHECKOUT_VALIDATION_AMOUNT_CENTS = 50) in constants/checkout.ts.
  3. confirmPayment cancels any pending DowngradeRequest, then confirmPayment on the processor (confirmPayment.ts).
  4. payment_intent.amount_capturable_updated: paymentIntentAmountCapturableUpdated:
    • If no subscription: createSubscription utility (local + Stripe).
    • If subscription exists: updateSubscription with old product quantity 0 and new product quantity from getQuantityBased, then updates subscriptions.product_id and quantity.

Downgrade

This flow schedules product downgrades in Stripe and finalizes them when webhooks confirm the new state.
  1. User selects target plan; confirmDowngrade GraphQL mutation runs (confirmDowngrade.ts).
  2. Cancel + create: confirmDowngrade cancels any existing pending DowngradeRequest row for the subscription (via paymentProcessor.cancelDowngradeRequest), then calls paymentProcessor.createDowngradeRequest to create/update the Stripe subscription schedule and write a downgrade_requests row (status = pending).
  3. Completion: customerSubscriptionUpdated runs handleOpenDowngradeRequests. It completes the row when Stripe’s items match downgradedToProduct.external_id and:
    • for product-changed downgrades, it hydrates subscription.product_id and subscription.quantity from Stripe
    • for quantity-only downgrades, it completes when stripeQuantity === downgradeRequest.changed_to_quantity (customerSubscriptionUpdated.ts).
  4. Cancel scheduled downgrade: cancelDowngrade + cancelDowngradeRequest.

Manual seat quantity sync with a pending product downgrade

This section keeps Stripe schedule phase 2 and DowngradeRequest.changed_to_quantity consistent after a manual seat change when a product downgrade is pending.
  1. Trigger: updateManualQuantity finds a pending product downgrade row (downgraded_to_product_id != currentProduct.id).
  2. Current plan update (IMMEDIATE): it resolves the effective current seats with getQuantityBased(currentProduct, ...), then updates Stripe subscription quantity via paymentProcessor.updateSubscription and persists subscription.quantity.
  3. Target plan sync: it calls syncPendingProductDowngradeScheduledQuantity, which resolves the effective target seats via getQuantityBased(downgradeRequest.downgradedToProduct, ...) before any Stripe/DB writes.
  4. Stripe + DB coupling: the sync helper rebuilds phases and updates the existing schedule in place via stripe.subscriptionSchedules.update(downgradeRequest.external_id, { phases }), then persists downgradeRequest.changed_to_quantity = resolvedSeatQuantity.
  5. Reconciliation (webhook): customerSubscriptionUpdated uses downgradeRequest.changed_to_quantity as scheduledQuantity for quantity-only completion and hydrates subscription fields from Stripe for product-changed downgrades (customerSubscriptionUpdated.ts).
⚠️ Watch out: DowngradeRequest.changed_to_quantity is the resolved seat count for downgradedToProduct. It must not be written from the raw UI quantity. The sync helper must call getQuantityBased before writing Stripe phase 2 or changed_to_quantity. (syncPendingProductDowngradeScheduledQuantity.ts)
⚠️ Watch out: updateManualQuantity selects the pending downgrade row inside a DB transaction using transaction.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 for cancelSubscription only hits the payment processor and webhooks).
  • When Stripe sends customer.subscription.deleted, customerSubscriptionDeleted:
    • Sets subscription status to cancelled.
    • Calls createDefaultSubscription to attach the org back to the default product (if that succeeds).

Renewal

  • Not implemented as a separate batch job. Renewals are implicit Stripe billing cycles.
  • customerSubscriptionUpdated updates cycle_start_date / cycle_end_date when period bounds change (shouldHydrateSubscriptionCycleDates).

5. Webhook handling

Events handled

From HandledStripeWebhookEvent:
Stripe typeHandler
customer.subscription.deletedcustomerSubscriptionDeleted
customer.subscription.updatedcustomerSubscriptionUpdated
payment_intent.amount_capturable_updatedpaymentIntentAmountCapturableUpdated
payment_intent.succeededpaymentIntentSucceeded
payment_method.attachedpaymentMethodAttached
payment_method.updatedpaymentMethodUpdated
payment_method.detachedpaymentMethodDetached
customer.updatedcustomerUpdated
Routing: getHandler.

Receive and verify

  1. HTTP: buildStripeRouter POST /webhook:
    • Stripe.webhooks.constructEvent with req.rawBody, stripe-signature, process.env.STRIPE_WEBHOOK_SECRET.
    • Drops events not in HandledStripeWebhookEvent with 200 (acknowledged but not queued).
    • hasWebhookEventBeenProcessed early exit → 200.
    • validateWebhookPayload → on failure 400 (body: Webhook Error: Invalid payload structure: ...).
    • On success, tools.queue.sendToQueue QueueName.STRIPE_WEBHOOK, payload JSON.stringify(event), respond 200.

Idempotency in the consumer

stripeWebhookConsumer:
  1. Open DB transaction.
  2. hasWebhookEventBeenProcessed (cache + processed_webhooks_events).
  3. cache.helpers.webhooksEvents.set with nx: true — if key exists, rollback and return (another worker owns the in-flight work).
  4. validateWebhookPayload; on failure delete cache key, rollback.
  5. Run handler; if handler returns false, delete cache key, rollback (event not recorded — Stripe may retry).
  6. 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 to processed_webhooks_events; cache key removed — safe retry on Stripe’s part.

Add a new webhook handler

  1. Add enum value to HandledStripeWebhookEvent (stripe.ts).
  2. Add Zod validation branch in webhookValidators.ts and payload type in webhookTypes.ts.
  3. Implement handler (payload, eventId, transaction, tools) => Promise<boolean>.
  4. Register in getHandler in index.ts.
  5. Extend HTTP router check (Object.values(HandledStripeWebhookEvent)) automatically if enum updated.
  6. Add tests under __tests__/.

6. Idempotency

Implementation

LayerMechanism
HTTP ingresshasWebhookEventBeenProcessed — Redis webhooks_events helper, then ProcessedWebhooksEvent by event_id.
WorkerSame check inside transaction; Redis SET NX as short-lived lock; DB unique event_id on ProcessedWebhooksEvent.
Usage reportingrecordUsage passes identifier: uniqueIdentifier to stripe.billing.meterEvents.create — dedupe at Stripe meter level when callers pass stable ids.

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 → no ProcessedWebhooksEvent row; Stripe retry re-processes.
  • Unsafe to assume: Returning true without idempotent business logic — always pair success with durable idempotency keys (DB unique + cache NX pattern used here).

Code reference

// apps/backend/src/utilities/billing.ts (abridged)
export const hasWebhookEventBeenProcessed = async (
  params: { eventId: string; transaction?: Transaction },
  tools: ToolsType,
) => {
  const isProcessedInCache = await webhooksEvents.get({ resource: eventId });
  if (isProcessedInCache) return true;
  const isProcessedInDatabase = await rds.models.ProcessedWebhooksEvent.findOne({
    where: { event_id: eventId },
    transaction,
  });
  return Boolean(isProcessedInDatabase);
};

7. 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, restaureProduct on StripePaymentProcessorType.
  • Per-org upgrades via checkout + webhooks (section 4).
  • Downgrade scheduling via createDowngradeRequest.

Practical guidance (grounded in code behavior)

  1. New plan for new customers only
    Insert new products row + Stripe Price; leave existing subscriptions on old product_id / Stripe price.
  2. Deprecate a plan
    Use retireProduct (processor) and hide the plan via the products query (see apps/backend/src/gql/queries/products.ts for out-of-stock/in-stock filtering). billingDetails currently returns all products (availableProducts = await rds.models.Product.findAll()), so the availability filter source of truth is the products query used by the UI/admin. Not yet implemented: automatic migration of existing Stripe subscriptions to a replacement price without going through checkout/downgrade flows.
  3. Change pricing without breaking existing subscriptions
    With Stripe, new Price objects grandfather existing subscriptions on old prices. Operationally: create a new Product row (or change only non-external fields) while keeping external_id stable for subscribers still on the old processor line — changing external_id for an in-use plan desyncs reconciliation unless every subscription is moved to the new processor identifier and DB rows updated. That bulk move is operational / custom scripting, not shipped here.
  4. Grandfathering
    Handled by Stripe subscription item price id + DB subscriptions.product_id remaining on legacy product rows.

8. Payment processor adapter

Interface definition

The concrete contract is StripePaymentProcessorType (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: buildStripePaymentProcessor constructs a real Stripe client from secretKey / apiVersion (or injected config.stripe for tests).
  • Settings: e.g. quantityDowngradePolicy passed into downgrade/sync helpers.

Swapping Stripe for another provider

Not implemented. buildPaymentProcessor only supports STRIPE and STRIPE_MOCK. Adding another provider requires:
  1. New PaymentProcessorClientType value + Zod branch in validate.ts.
  2. 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)

When to use: tests, local environments without Stripe, CI that mocks processors. Do not use in production if you need real billing.

9. Free plan and upgrade triggers

Free plan

  • Identified by Product.is_default === true and/or price === 0 when comparing for upgrades (isFreeToPaidUpgrade).
  • createDefaultSubscription always binds new orgs to is_default: true product.

Upgrade prompts (frontend)

Customize triggers

  • UI: Gating is standard React routing + feature checks; search frontend for billing routes and scope checks (generated ScopeEnum in hooks.ts aligns with AllScopes names).
  • API: Add or restrict AllScopes on roles via products_roles_scopes for each plan.

10. Extending the billing system

New billing provider

See section 8 — requires factory changes and a full implementation of the operations callers use (createSubscription, updateSubscription, webhooks, etc.).

Usage-based / metered billing

  • Product metadata: ProductType.UsageBased with eventName, externalUsageId, pricingBands (product.ts).
  • Reporting usage: call paymentProcessor.recordUsage — uses stripe.billing.meterEvents.create and increments SubscriptionUsageCycle when cycle dates exist (recordUsage.ts).
  • Checkout / subscription items: createIntent appends meter price item when metadata.type === ProductType.UsageBased.
Not guaranteed: every edge path (e.g. all downgrade combinations for usage-based) — validate against createDowngradeRequest eligibility in createDowngradeRequest.ts and tests.

New plan tier

  1. Stripe Price + DB products row.
  2. Seed products_roles_scopes for each PublicRoles value that should differ per tier.
  3. Expose in admin / super-admin GraphQL if using built-in product management mutations.

Customize checkout flow

11. Common pitfalls

PitfallDetail
Mutating Product.external_id for live plansBreaks reconciliation for the Stripe adapter: webhook line matching and quantity hydration compare resolved item ids to subscription.product.external_id (customerSubscriptionUpdated). Other adapters would have analogous coupling to their own id scheme.
Assuming GraphQL = immediate Stripe statePaid upgrades finalize in webhooks (payment_intent.*) — UI should tolerate short delay or poll billing data.
Double chargesUse one checkout session per upgrade; confirmPayment cancels open DowngradeRequest before confirming (confirmPayment.ts). Hold PI is 50 cents validation for paid-to-paid — understand Stripe test vs live behavior.
Stripe statuses paused / incomplete / incomplete_expiredMapped to null in stripeSubscriptionStatusToSubscriptionStatus — “not supported” for internal status updates.
Webhook secret mismatchHTTP router uses process.env.STRIPE_WEBHOOK_SECRET — must match Stripe dashboard endpoint secret.
Scope cache stalenessNormally cleared via ProductRoleScope hooks + queue; if you bulk-edit DB, run cache invalidation or enqueue SCOPE_CACHE_RESET manually.

Testing without real charges

Known edge cases

  • Multiple pending downgrade requests: DB migration downgrade_requests_one_pending_per_subscription (partial unique on subscription_id where status = 'pending') enforces at most one pending row per subscription in 20250608010922-addDowngradeMarking.ts.
  • Handler false vs thrown error: false triggers 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).

What’s next?

If you’re adding a new billing capability, wire it through scopes by reading apps/documentation/architecture/RBAC.md next.
📖 See also: RBAC for how hasScopes resolves permissions from product-role mappings and Redis cache.