10-cross-cuttingwave: W6filled14 citations

SaaS Billing — Extracted to the ddx-saas Service

Audiences: developer, internal, investor

SaaS Billing — Extracted to the ddx-saas Service

⚠ ARCHITECTURE CHANGE (verified at 09e374b3). The in-ddx-api Stripe subscription module documented by earlier revisions of this shard no longer exists. ddx-api/src/financial/billing-saas/billing-saas.module.ts is now an empty deprecated barrel (@Module({})), kept for one release so existing app.module.ts imports don't break. SaaS subscription billing — plan tiers, Stripe checkout, webhook lifecycle, licensing and metering — was extracted to the standalone ddx-saas service. ddx-api now consumes it over REST via SaasClientService (src/platform/saas-client/). For the ddx-saas-side subscription/licensing/metering internals see the ddx-saas module shards (11-registration-and-onboarding/).

What lives where now

ConcernLocation (verified 09e374b3)
SaaS subscription/plan/checkout/webhook engineddx-saas service (separate NestJS app) — NOT ddx-api
ddx-api → ddx-saas REST clientddx-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 endpointddx-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:

  1. Plan catalogue — three tiers (STARTER, PRO, ENTERPRISE) stored in ddx_api_main.plans, synced idempotently from Stripe Products via syncPlansFromStripe().
  2. Public checkout flow — unauthenticated POST /billing/checkout endpoint generates a Stripe Checkout Session with plan metadata embedded; a new clinic operator starts a subscription without a pre-existing account.
  3. Webhook lifecyclePOST /billing/webhook (@Public()) verifies HMAC, dispatches on checkout.session.completed / customer.subscription.updated / customer.subscription.deleted, and publishes tenant.provision / tenant.deprovision Redis messages.
  4. 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 from BillingModule (src/financial/billing/). The module comment states: "Do not cross-import the two." They share no providers. BillingSaasService writes to ddx_api_main.plans and ddx_api_main.subscription — NOT to ddx_api_acc.
  • Internal (ops/support): Stripe events are idempotent by design (AC-1): the stripeEventId field on Subscription rows has a @unique constraint; duplicate webhook deliveries are silently skipped. The syncPlansFromStripe() admin action reconciles Stripe Products to the local plans table without creating duplicates.

Architecture

diagram
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

ConcernChoiceRationale
Payment providerStripe SDK ^18.5.0, API version 2025-08-27.basilStandard EU SaaS payment rails; 3DS2 compliance handled by Stripe Checkout
Webhook verificationstripe.webhooks.constructEvent(rawBody, signature, secret)HMAC over raw bytes; requires rawBody: true in main.ts Express options
IdempotencystripeEventId @unique on SubscriptionStripe can redeliver events; idempotency check happens BEFORE any side effects
Provisioning handoffRedis PUBLISH tenant.provision (ioredis)Decouples heavy HAPI/MinIO provisioning from the webhook HTTP response cycle; provisioner handles retries independently
Plan storageddx_api_main.plans (Prisma main)Plans are platform-level data, not tenant-specific; collocated with subscription on the main DB
Plan tier enumPlanTier 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 UIStripe-hosted Checkout pageNo 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:1BillingSaasModule; imports PrismaModule and ConfigModule; registers StripeService, BillingSaasService, PublicCheckoutController, StripeWebhookController. Comment at line 12 explicitly forbids cross-importing with BillingModule.

Stripe client

  • ddx-api/src/financial/billing-saas/services/stripe.service.ts:13StripeService implements OnModuleInit; constructs Stripe client with apiVersion: '2025-08-27.basil' and appInfo: {name: 'ddx-api', version: '0.7.7'}.
  • ddx-api/src/financial/billing-saas/services/stripe.service.ts:19onModuleInit(): logs warning if STRIPE_API_KEY absent, leaves _client null (lazy fault — callers see Error on first use).
  • ddx-api/src/financial/billing-saas/services/stripe.service.ts:52getWebhookSecret(): reads STRIPE_WEBHOOK_SECRET from ConfigService.

Plan CRUD and sync

  • ddx-api/src/financial/billing-saas/services/billing-saas.service.ts:29BillingSaasService; listPlans(), getPlanById(), getPlanByTier() read from ddx_api_main.plans.
  • ddx-api/src/financial/billing-saas/services/billing-saas.service.ts:63syncPlansFromStripe(): lists all active Stripe Products (expand default_price), validates tier / maxUsers metadata, upserts by stripeProductId; logs created/updated/skipped/total counts. Products without tier or maxUsers metadata are skipped.

Webhook controller

  • ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:65StripeWebhookController; @Controller('billing'), @Public() on POST webhook — Stripe does not send gateway headers.
  • ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:103handleWebhook(): 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:195handleCheckoutCompleted(): publishes tenant.provision Redis message with TenantProvisionPayload.
  • ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:269handleSubscriptionDeleted(): flips CANCELED status, publishes tenant.deprovision.
  • ddx-api/src/financial/billing-saas/stripe-webhook.controller.ts:311mapStripeStatus(): maps Stripe subscription status strings to TRIALING | ACTIVE | PAST_DUE | CANCELED.

DTOs

  • ddx-api/src/financial/billing-saas/dto/plan.dto.ts:1PlanResponse, PlanFeatures, SyncPlansResult shapes.
  • ddx-api/src/financial/billing-saas/dto/create-checkout.dto.ts:1CreateCheckoutDto.

Operational Notes

  • STRIPE_API_KEY and STRIPE_WEBHOOK_SECRET must both be set for the module to be fully functional. If STRIPE_API_KEY is absent, StripeService logs a warning at boot and every call to stripe.client throws. If STRIPE_WEBHOOK_SECRET is absent, all webhooks return {received: false} without processing.
  • Raw body capture is requiredmain.ts must enable rawBody: true in the Express factory for stripe.webhooks.constructEvent() to succeed. If req.rawBody is absent, the webhook controller logs an error and returns {received: false}.
  • Stripe API version pinned to 2025-08-27.basil — SDK version ^18.5.0 is pinned to this version. Stripe API changes are breaking by version; do not update the apiVersion string without checking the SDK changelog.
  • Redis channel consumers — the tenant.provision and tenant.deprovision channels are consumed by the provisioner process (separate from ddx-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) and metadata.maxUsers (numeric string) for syncPlansFromStripe() 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.
    SaaS Billing — Extracted to the ddx-saas Service — Dudoxx Docs | Dudoxx