SaaS Billing — Extracted to the ddx-saas Service
Audiences: developer, internal, investor
⚠ ARCHITECTURE CHANGE (verified at
09e374b3). The in-ddx-apiStripe subscription module documented by earlier revisions of this shard no longer exists.ddx-api/src/financial/billing-saas/billing-saas.module.tsis now an empty deprecated barrel (@Module({})), kept for one release so existingapp.module.tsimports don't break. SaaS subscription billing — plan tiers, Stripe checkout, webhook lifecycle, licensing and metering — was extracted to the standaloneddx-saasservice.ddx-apinow consumes it over REST viaSaasClientService(src/platform/saas-client/). For theddx-saas-side subscription/licensing/metering internals see theddx-saasmodule shards (11-registration-and-onboarding/).
What lives where now
| Concern | Location (verified 09e374b3) |
|---|---|
| SaaS subscription/plan/checkout/webhook engine | ddx-saas service (separate NestJS app) — NOT ddx-api |
ddx-api → ddx-saas REST client | ddx-api/src/platform/saas-client/saas-client.service.ts |
| License keyset (signature verification) | ddx-api/src/platform/saas-client/license-keyset.service.ts |
| Usage metering emitter (ddx-api → ddx-saas) | ddx-api/src/platform/saas-client/metering-emitter.service.ts |
| Per-tenant license read endpoint | ddx-api/src/platform/saas-client/saas-license.controller.ts |
| Deprecated empty barrel (delete next cleanup) | ddx-api/src/financial/billing-saas/billing-saas.module.ts |
| Patient appointment payments (B2C, Stripe Connect + SumUp) | ddx-api/src/financial/appointment-payments/ → see Appointment Payments |
The module comment is explicit: "SaaS subscription billing moved to the ddx-saas
service. This module is an empty barrel kept for one release... For new code: use
the ddx-saas REST API directly via SaasClientService from
src/platform/saas-client/."
Migration note
Everything below the divider describes the former in-ddx-api implementation
(historical, retained for context on the pre-extraction design). Treat it as the
design that the ddx-saas service now owns, not as current ddx-api code. The
Stripe integration mechanics (webhook HMAC verification, idempotency by
stripeEventId, tenant provisioning handoff via Redis) carried over conceptually
to ddx-saas; the concrete controllers/services named below are gone from
ddx-api.
(HISTORICAL) SaaS Billing — Stripe Subscription Management
The former Dudoxx-as-vendor billing module. No longer in ddx-api.
The SaaS billing module covered:
- Plan catalogue — three tiers (
STARTER,PRO,ENTERPRISE) stored inddx_api_main.plans, synced idempotently from Stripe Products viasyncPlansFromStripe(). - Public checkout flow — unauthenticated
POST /billing/checkoutendpoint generates a Stripe Checkout Session with plan metadata embedded; a new clinic operator starts a subscription without a pre-existing account. - Webhook lifecycle —
POST /billing/webhook(@Public()) verifies HMAC, dispatches oncheckout.session.completed/customer.subscription.updated/customer.subscription.deleted, and publishestenant.provision/tenant.deprovisionRedis messages. - Tenant provisioning handoff — the webhook does NOT directly create HAPI FHIR partitions or MinIO buckets; it publishes a Redis event so the provisioner process can handle the heavy operations asynchronously and independently.
Status: the module is operational in code — all files are present, typed, and wired — but Stripe live keys are not configured in the production environment as of 2026-05-18. StripeService.onModuleInit() logs a warning if STRIPE_API_KEY is absent and the client remains null. Webhooks return {received: false} if the secret is missing. The module is aspirationally positioned for the initial SaaS launch.
Audiences
- Investor: SaaS billing demonstrates that Dudoxx has a repeatable, automated subscription revenue model. Stripe handles payment method collection, 3DS compliance, and invoicing; Dudoxx engineering owns only the provisioning handshake. The plan tier model (STARTER/PRO/ENTERPRISE) supports a standard land-and-expand motion.
- Clinical buyer (doctor/nurse/receptionist): Invisible — clinic operators interact with the Stripe-hosted checkout page; no Dudoxx UI is involved in the payment step.
- Developer/partner:
BillingSaasModule(src/financial/billing-saas/) is explicitly isolated fromBillingModule(src/financial/billing/). The module comment states: "Do not cross-import the two." They share no providers.BillingSaasServicewrites toddx_api_main.plansandddx_api_main.subscription— NOT toddx_api_acc. - Internal (ops/support): Stripe events are idempotent by design (AC-1): the
stripeEventIdfield onSubscriptionrows has a@uniqueconstraint; duplicate webhook deliveries are silently skipped. ThesyncPlansFromStripe()admin action reconciles Stripe Products to the localplanstable without creating duplicates.
Architecture
BillingSaasModule (ddx-api/src/financial/billing-saas/)
│
├── PublicCheckoutController POST /billing/checkout (@Public())
│ BillingSaasService.createCheckoutSession(planId, successUrl, cancelUrl)
│ → stripe.client.checkout.sessions.create({...})
│ → returns {url: <stripe checkout URL>}
│
├── StripeWebhookController POST /billing/webhook (@Public())
│ raw body required (main.ts rawBody: true)
│ stripe.client.webhooks.constructEvent(rawBody, signature, secret)
│
│ checkout.session.completed
│ → Redis PUBLISH 'tenant.provision' JSON payload
│
│ customer.subscription.updated
│ → prisma.subscription.update(status, currentPeriodEnd, cancelAtPeriodEnd)
│
│ customer.subscription.deleted
│ → prisma.subscription.update(status: 'CANCELED')
│ → Redis PUBLISH 'tenant.deprovision' JSON payload
│
├── StripeService OnModuleInit — constructs Stripe client
│ STRIPE_API_KEY → Stripe({apiVersion: '2025-08-27.basil'})
│ STRIPE_WEBHOOK_SECRET → passed to constructEvent()
│
└── BillingSaasService Plan CRUD + Stripe sync
listPlans() / getPlanById() / getPlanByTier()
syncPlansFromStripe() ← idempotent Stripe→DB reconciliation (AC-1)
ddx_api_main tables used:
plans (id, tier, name, stripeProductId UNIQUE, stripePriceId, monthlyPriceCents, maxUsers, features, active)
subscription (id, stripeSubscriptionId UNIQUE, stripeCustomerId, status, stripeEventId UNIQUE, currentPeriodEnd, cancelAtPeriodEnd)
Redis channels:
tenant.provision → consumed by provisioner process (creates HAPI partition + MinIO buckets)
tenant.deprovision → consumed by provisioner (tears down tenant resources)
Tech Stack & Choices
| Concern | Choice | Rationale |
|---|---|---|
| Payment provider | Stripe SDK ^18.5.0, API version 2025-08-27.basil | Standard EU SaaS payment rails; 3DS2 compliance handled by Stripe Checkout |
| Webhook verification | stripe.webhooks.constructEvent(rawBody, signature, secret) | HMAC over raw bytes; requires rawBody: true in main.ts Express options |
| Idempotency | stripeEventId @unique on Subscription | Stripe can redeliver events; idempotency check happens BEFORE any side effects |
| Provisioning handoff | Redis PUBLISH tenant.provision (ioredis) | Decouples heavy HAPI/MinIO provisioning from the webhook HTTP response cycle; provisioner handles retries independently |
| Plan storage | ddx_api_main.plans (Prisma main) | Plans are platform-level data, not tenant-specific; collocated with subscription on the main DB |
| Plan tier enum | PlanTier from @ddx/prisma-main (STARTER | PRO | ENTERPRISE) | Typed at the ORM level; Stripe Product metadata tier field is validated against this union on sync |
| Checkout UI | Stripe-hosted Checkout page | No PCI surface in Dudoxx codebase; Stripe handles card tokenisation |
Data Flow
Subscription lifecycle (happy path)
1. Operator visits signup page (ddx-web)
→ GET /billing/plans → BillingSaasService.listPlans() → plans table
2. Operator selects plan, clicks "Subscribe"
→ POST /billing/checkout {planId, successUrl, cancelUrl}
→ BillingSaasService.createCheckoutSession(...)
→ stripe.checkout.sessions.create({
line_items: [{price: plan.stripePriceId}],
metadata: {planTier, adminEmail, clinicName}
})
→ returns {url} ← redirect browser
3. Operator completes Stripe Checkout
→ Stripe sends POST /billing/webhook {type: 'checkout.session.completed'}
→ StripeWebhookController.handleWebhook()
a. HMAC verification (rawBody + Stripe-Signature)
b. Idempotency check (stripeEventId not in subscription table)
c. handleCheckoutCompleted():
payload = {planTier, stripeCustomerId, stripeSubscriptionId, adminEmail, clinicName, stripeEventId}
Redis.publish('tenant.provision', JSON.stringify(payload))
4. Provisioner process receives tenant.provision
→ Creates HAPI FHIR partition + Organization FHIR resource
→ Creates MinIO buckets for clinic slug
→ Creates User (admin) + Subscription row in ddx_api_main
→ Sends onboarding email to adminEmail
5. Subscription renewal / update
→ Stripe sends customer.subscription.updated
→ StripeWebhookController.handleSubscriptionUpdated()
→ prisma.subscription.update(status, currentPeriodEnd, cancelAtPeriodEnd)
6. Cancellation
→ Stripe sends customer.subscription.deleted
→ StripeWebhookController.handleSubscriptionDeleted()
→ prisma.subscription.update(status: 'CANCELED')
→ Redis.publish('tenant.deprovision', {stripeCustomerId, stripeSubscriptionId, stripeEventId})
Implicated Code
Module
ddx-api/src/financial/billing-saas/billing-saas.module.ts:1—BillingSaasModule; importsPrismaModuleandConfigModule; registersStripeService,BillingSaasService,PublicCheckoutController,StripeWebhookController. Comment at line 12 explicitly forbids cross-importing withBillingModule.
Stripe client
ddx-api/src/financial/billing-saas/services/stripe.service.ts:13—StripeService implements OnModuleInit; constructsStripeclient withapiVersion: '2025-08-27.basil'andappInfo: {name: 'ddx-api', version: '0.7.7'}.ddx-api/src/financial/billing-saas/services/stripe.service.ts:19—onModuleInit(): logs warning ifSTRIPE_API_KEYabsent, leaves_clientnull (lazy fault — callers seeErroron first use).ddx-api/src/financial/billing-saas/services/stripe.service.ts:52—getWebhookSecret(): readsSTRIPE_WEBHOOK_SECRETfromConfigService.
Plan CRUD and sync
ddx-api/src/financial/billing-saas/services/billing-saas.service.ts:29—BillingSaasService;listPlans(),getPlanById(),getPlanByTier()read fromddx_api_main.plans.ddx-api/src/financial/billing-saas/services/billing-saas.service.ts:63—syncPlansFromStripe(): lists all active Stripe Products (expanddefault_price), validatestier/maxUsersmetadata, upserts bystripeProductId; logscreated/updated/skipped/totalcounts. Products withouttierormaxUsersmetadata are skipped.
Webhook controller
ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:65—StripeWebhookController;@Controller('billing'),@Public()onPOST webhook— Stripe does not send gateway headers.ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:103—handleWebhook(): raw-body check → HMAC verify → idempotency check →dispatch(event).ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:143— idempotency guard:prisma.subscription.findUnique({where: {stripeEventId: event.id}})BEFORE any side effects (AC-1 pattern).ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:195—handleCheckoutCompleted(): publishestenant.provisionRedis message withTenantProvisionPayload.ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:269—handleSubscriptionDeleted(): flipsCANCELEDstatus, publishestenant.deprovision.ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:311—mapStripeStatus(): maps Stripe subscription status strings toTRIALING | ACTIVE | PAST_DUE | CANCELED.
DTOs
ddx-api/src/financial/billing-saas/dto/plan.dto.ts:1—PlanResponse,PlanFeatures,SyncPlansResultshapes.ddx-api/src/financial/billing-saas/dto/create-checkout.dto.ts:1—CreateCheckoutDto.
Operational Notes
STRIPE_API_KEYandSTRIPE_WEBHOOK_SECRETmust both be set for the module to be fully functional. IfSTRIPE_API_KEYis absent,StripeServicelogs a warning at boot and every call tostripe.clientthrows. IfSTRIPE_WEBHOOK_SECRETis absent, all webhooks return{received: false}without processing.- Raw body capture is required —
main.tsmust enablerawBody: truein the Express factory forstripe.webhooks.constructEvent()to succeed. Ifreq.rawBodyis absent, the webhook controller logs an error and returns{received: false}. - Stripe API version pinned to
2025-08-27.basil— SDK version^18.5.0is pinned to this version. Stripe API changes are breaking by version; do not update theapiVersionstring without checking the SDK changelog. - Redis channel consumers — the
tenant.provisionandtenant.deprovisionchannels are consumed by the provisioner process (separate fromddx-api). If the provisioner is not running when a webhook fires, the Redis message may expire. Implement a dead-letter queue or persistent job store if provisioning reliability is critical. - Plan metadata convention — Stripe Products MUST have
metadata.tier(STARTER | PRO | ENTERPRISE) andmetadata.maxUsers(numeric string) forsyncPlansFromStripe()to import them. Products with missing or invalid metadata are skipped with a warning log. syncPlansFromStripe()is not automatically scheduled — it must be called manually (or via a cron job) after updating Stripe Products. There is no webhook event for product changes.
Related Topics
- Billing — Invoices, Charge Items and Pricing — patient billing (B2C); distinct module, distinct DB tables, no cross-import.
- Accounting — Journal Entries, Budgets and Financial Reports — the
plansandsubscriptionrows live inddx_api_main, notddx_api_acc; SaaS revenue is not run through the double-entry accounting ledger today. - Prisma 7 Schemas —
ddx_api_main(viaPrismaService) is the only Prisma database used byBillingSaasModule. - Stripe Catalog as Code & the Webhook Dev-Bridge — the
ddx-stripeCLI that authors the products/prices this module reads bylookup_key, and thestripe listendev-bridge that forwards the webhooks this module'sPOST /billing/webhookconsumes.