Skip to main content

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 products row may set onboarding_trial JSON (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 satisfy phasedTrialEligibleProduct and cannot equal the subscription’s current product (trialProductMatchesSubscriptionProduct — see apps/backend/src/utilities/trials.ts). When the SKU is not the onboarding-designated row, pass trialPhaseTimeUnit + 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.
Trial phase length is capped server-side to Stripe subscription trial horizons (about 730 days soft maximum). Portal Super Admin requires explicit acknowledgement for phased lengths above about 30 days (same order of magnitude as a calendar month).

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

  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:

What not to do

File conventions

1. Overview

High-level summary

Most teams think in subscriptions first—and rightly so: each organization has a subscriptions 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.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_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 (internal products.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 on products.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 pinned price_id when set (including retired rows), otherwise the product’s active default price row.
  • Webhooks: findCatalogRowByStripePriceId looks up prices.external_id (including retired prices and retired parent products with paranoid: false) so Stripe price_* ids on grandfathered subscriptions still map to catalog rows.
Product retirement (paranoid products.retired_at):
  • Catalog listings (publicProducts, billingDetails.availableProducts, non-SA products) 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.
Active-default catalog invariants (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.
Checkout 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 prices row is the flat base Stripe price; prices.metadata.externalUsageId holds a second metered Stripe price id (not the default row). getUsageSubscriptionPriceIds returns base + metered when present.
  • Stripe line-item matching: webhooks and adapter code compare SubscriptionItem.price.id (via resolveStripeSubscriptionItemExternalId) to Stripe price ids from the resolution layer, then map back to catalog rows with findCatalogRowByStripePriceId.
  • 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 Product rows (ProductType.Recurring or ProductType.OneOff) with thin metadata ({ model: seat_based | usage_based } for recurring, {} for one-off) in apps/backend/src/tools/rds/sequelize/models/product.ts. Commercial terms live on associated Price rows.
  • 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

  • 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 plus one or more Price rows. See Product vs Price. On products:
  • type: ProductType.Recurring or ProductType.OneOff.
  • metadata: thin packaging discriminator only:
    • Recurring seat-based: { model: RecurringModel.SeatBased }
    • Recurring usage-based: { model: RecurringModel.UsageBased }
    • One-off: {}
  • external_id: Stripe Product id (prod_*).
  • is_default: marks the free / default plan used when creating a default subscription (createDefaultSubscription loads where: { is_default: true }). The base free plan must also have a zero default price (isBaseFreeDefaultPlan / productCatalog.ts).
On prices:
  • external_id: Stripe Price id (price_*) — the billable subscription line.
  • unit_amount, currency, billing_period, quantity_based: canonical commercial terms.
  • metadata: seat resourceName; usage resourceName, eventName, externalUsageId (metered Stripe price id), pricingBands (see priceMetadata.ts).
  • is_default: which price row new checkouts use when checkout_sessions.price_id is 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 via ProductRoleScope (table products_roles_scopes, model productRoleScope.ts).

Runtime entitlement checks

  1. Resolver uses hasScopes middleware (hasScopes.ts).
  2. canAccessScopes → getScopesForOrganizationMembership:
    • 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).
  • cancelOrganizationTrial: Omit organizationId to cancel the trial for the current membership organization (portal and org admins). hasScopes enforces BILLING_DOWNGRADE_SUBSCRIPTIONS for everyone except super admins (super admins bypass that check). Non-super-admins must not pass organizationId (403 if they do). Super admins may pass organizationId to target a different organization.

Define a new plan (code / ops)

Preferred: Super Admin Packages UI or paymentProcessor.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):
  1. Create Stripe Product + Price(s); insert products with external_id = prod_* and thin product metadata ({ model: 'seat_based' } / { model: 'usage_based' } / {}).
  2. Insert a default prices row with external_id = price_*, amount/currency/interval, and any usage metadata.
  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 shapes (conceptual):

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.

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 is OneOff.

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
  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 against resolved Stripe price ids, updates subscriptions (external_id, product_id, price_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).
    • 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).
  3. Completion: customerSubscriptionUpdated runs handleOpenDowngradeRequests. 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, 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).

6. Webhook handling

Events handled

From HandledStripeWebhookEvent:

Payment method ownership

Local payment_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.created creates initial invoice rows (including open).
  • invoice.updated updates lifecycle state changes (including void).
  • invoice.paid / invoice.payment_succeeded / invoice_payment.paid stay enabled for checkout finalization flows and as compatibility ingestion paths.
One-off receipt sync (document_type = receipt) remains owned by payment_intent.succeeded and processor confirmPayment one-off finalize paths. Routing: getHandler.

Receive and verify

  1. HTTP: createStripeWebhookHandler on POST /api/stripe/webhook, registered in index.ts before express.json() with express.raw so req.body is the raw buffer:
    • Stripe.webhooks.constructEvent with that buffer, stripe-signature, and the signing secret from typed config: getWebhookSigningSecret reads tools.configuration.tools.paymentProcessor.webhookSigningSecret (for Stripe, sourced from env such as STRIPE_WEBHOOK_SECRET in apps/backend/src/configuration/development.ts).
    • 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__/.

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 → 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

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

Practical guidance (grounded in code behavior)

  1. New plan for new customers only
    Use createProduct (products + default prices row). Leave existing subscriptions on their current product_id / pinned price_id.
  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 loads 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 the products query. 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
    Create a new prices row (new Stripe Price), promote it to default, retire the old price row. Existing subscriptions keep billing on the old rate via pinned subscriptions.price_id. Do not change products.external_id (prod_*) for live plans.
  4. Grandfathering
    Handled by Stripe subscription item price id + DB subscriptions.price_id (pinned internal prices.id, including retired price rows).

9. 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.

10. Free plan and upgrade triggers

Free plan

  • Identified by Product.is_default === true and default price unit_amount === 0 (isBaseFreeDefaultPlan, used by 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.

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.Recurring with metadata.model === RecurringModel.UsageBased (product.ts).
  • Price commercial terms: default prices row holds flat base amount; prices.metadata holds eventName, externalUsageId, pricingBands, resourceName (priceMetadata.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 items from getUsageSubscriptionPriceIds when the product is usage-based.
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. createProduct via Super Admin or processor API (Stripe Product + Price, products + prices rows).
  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

12. Common pitfalls

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).
  • Currency change on an active subscription: not supported in-product. Prefer cancel → re-subscribe on the target currency (Single currency).

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. For phased trial overlap and post‑trial fallback, the rule lives in apps/backend/src/utilities/trials.ts (see Pending downgrade overlap vs nominal post‑trial fallback above).