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: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
Billing is subscription-centric: each organization has a row insubscriptions 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.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).
External IDs and swappable processors
Product.external_id(and related fields such as usagemetadata.externalUsageIdwhere applicable) are processor-facing identifiers: they link a RefractProductto 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_*) inProduct.external_idand matches webhook/API line items viaresolveStripeSubscriptionItemExternalIdagainstSubscriptionItem.price.id(with a legacyplan.idfallback). - Another processor: the same column holds that provider’s canonical id for the equivalent line item; webhook and
paymentProcessorcode for that adapter must compare against the same contract. The domain model stays processor-agnostic; only adapter implementations are Stripe-specific.
Key architectural decisions
| Decision | Rationale (as reflected in code) |
|---|---|
Product.external_id is the processor’s id for the plan’s primary line | Not 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 verify | apps/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 + transaction | Prevents 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
Productrows with typedmetadata(seat-based vs usage-based) inapps/backend/src/tools/rds/sequelize/models/product.ts. - 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,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:
external_id: Payment processor identifier for this plan’s primary subscription line (Stripe adapter: Price idprice_*— see External IDs and swappable processors).metadata: discriminated bytype:ProductType.SeatBased:quantityBased(admin|allMembers|manual|none),resourceName.ProductType.UsageBased:externalUsageId,eventName,pricingBands, etc. (see Zod schemas inproduct.ts).
is_default: marks the free / default plan used when creating a default subscription (createDefaultSubscriptionloadswhere: { is_default: true }).price: amount in smallest currency unit;isFreeToPaidUpgradetreatsprice === 0oris_default === trueas “free” side (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).
Define a new plan (code / ops)
- Stripe (current adapter): create Stripe Product + Price(s); set
Product.external_idto the Price id (price_*). For usage-based plans, also setmetadata.externalUsageIdto the processor’s usage/meter price id as created by your Stripe flows. - Database: insert into
productswith validmetadataJSON matching Zod (zodSeatBasedProductMetadataorzodUsageBasedProductMetadata). - 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.
4. 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 vs checkout session, updatessubscriptions(external_id,product_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). -
Completion:
customerSubscriptionUpdatedrunshandleOpenDowngradeRequests. It completes the row when Stripe’s items matchdowngradedToProduct.external_idand:- for product-changed downgrades, it hydrates
subscription.product_idandsubscription.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).
5. Webhook handling
Events handled
FromHandledStripeWebhookEvent:
Stripe type | Handler |
|---|---|
customer.subscription.deleted | customerSubscriptionDeleted |
customer.subscription.updated | customerSubscriptionUpdated |
payment_intent.amount_capturable_updated | paymentIntentAmountCapturableUpdated |
payment_intent.succeeded | paymentIntentSucceeded |
payment_method.attached | paymentMethodAttached |
payment_method.updated | paymentMethodUpdated |
payment_method.detached | paymentMethodDetached |
customer.updated | customerUpdated |
getHandler.
Receive and verify
- HTTP:
buildStripeRouterPOST /webhook:Stripe.webhooks.constructEventwithreq.rawBody,stripe-signature,process.env.STRIPE_WEBHOOK_SECRET.- 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__/.
6. Idempotency
Implementation
| Layer | Mechanism |
|---|---|
| HTTP ingress | hasWebhookEventBeenProcessed — Redis webhooks_events helper, then ProcessedWebhooksEvent by event_id. |
| Worker | Same check inside transaction; Redis SET NX as short-lived lock; DB unique event_id on ProcessedWebhooksEvent. |
| Usage reporting | recordUsage 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→ 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
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,restaureProductonStripePaymentProcessorType. - Per-org upgrades via checkout + webhooks (section 4).
- Downgrade scheduling via
createDowngradeRequest.
Practical guidance (grounded in code behavior)
-
New plan for new customers only
Insert newproductsrow + Stripe Price; leave existing subscriptions on oldproduct_id/ Stripe price. -
Deprecate a plan
UseretireProduct(processor) and hide the plan via theproductsquery (seeapps/backend/src/gql/queries/products.tsfor out-of-stock/in-stock filtering).billingDetailscurrently returns all products (availableProducts = await rds.models.Product.findAll()), so the availability filter source of truth is theproductsquery used by the UI/admin. 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
With Stripe, new Price objects grandfather existing subscriptions on old prices. Operationally: create a newProductrow (or change only non-external fields) while keepingexternal_idstable for subscribers still on the old processor line — changingexternal_idfor 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. -
Grandfathering
Handled by Stripe subscription item price id + DBsubscriptions.product_idremaining on legacy product rows.
8. 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.).
9. Free plan and upgrade triggers
Free plan
- Identified by
Product.is_default === trueand/orprice === 0when comparing for upgrades (isFreeToPaidUpgrade). createDefaultSubscriptionalways binds new orgs tois_default: trueproduct.
Upgrade prompts (frontend)
- Checkout state:
checkoutSlice.ts—setProductresolves quantity usinggetQuantityBasedfromapps/frontend/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.
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.UsageBasedwitheventName,externalUsageId,pricingBands(product.ts). - Reporting usage: call
paymentProcessor.recordUsage— usesstripe.billing.meterEvents.createand incrementsSubscriptionUsageCyclewhen cycle dates exist (recordUsage.ts). - Checkout / subscription items:
createIntentappends meter price item whenmetadata.type === ProductType.UsageBased.
createDowngradeRequest eligibility in createDowngradeRequest.ts and tests.
New plan tier
- Stripe Price + DB
productsrow. - 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/frontend/src(grepcreateIntent,confirmPayment,invoicePreview).
11. Common pitfalls
| Pitfall | Detail |
|---|---|
Mutating Product.external_id for live plans | Breaks 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 state | Paid upgrades finalize in webhooks (payment_intent.*) — UI should tolerate short delay or poll billing data. |
| Double charges | Use 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_expired | Mapped to null in stripeSubscriptionStatusToSubscriptionStatus — “not supported” for internal status updates. |
| Webhook secret mismatch | HTTP router uses process.env.STRIPE_WEBHOOK_SECRET — must match Stripe dashboard endpoint secret. |
| Scope cache staleness | Normally cleared via ProductRoleScope hooks + queue; if you bulk-edit DB, run cache invalidation or enqueue SCOPE_CACHE_RESET manually. |
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).
Related files (quick index)
| Area | Path |
|---|---|
| Processor factory | apps/backend/src/tools/paymentProcessor/index.ts |
| Stripe surface | apps/backend/src/tools/paymentProcessor/stripe/types.ts |
| Webhook consumer | apps/backend/src/tools/queue/consumers/stripeWebhookConsumer/index.ts |
| Webhook HTTP | apps/backend/src/routers/api/stripe.ts |
| Subscription helpers | apps/backend/src/utilities/subscription.ts |
| RBAC | apps/backend/src/utilities/rbac.ts |
| Idempotency helper | apps/backend/src/utilities/billing.ts |
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 how hasScopes resolves permissions from product-role mappings and Redis cache.