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

BillingSaasModule (ddx-api/src/financial/billing-saas/) owns:

Controller / ServiceRoute / hookBehavior
PublicCheckoutControllerPOST /billing/checkout (@Public())BillingSaasService.createCheckoutSession(planId, successUrl, cancelUrl)stripe.client.checkout.sessions.create({...}) → returns {url: <stripe checkout URL>}
StripeWebhookControllerPOST /billing/webhook (@Public())raw body required (main.ts rawBody: true) · stripe.client.webhooks.constructEvent(rawBody, signature, secret)
StripeServiceOnModuleInitconstructs Stripe client — STRIPE_API_KEYStripe({apiVersion: '2025-08-27.basil'}) · STRIPE_WEBHOOK_SECRET → passed to constructEvent()
BillingSaasServicePlan CRUD + Stripe sync: listPlans() / getPlanById() / getPlanByTier() · syncPlansFromStripe() ← idempotent Stripe→DB reconciliation (AC-1)

Webhook event handling

Stripe eventAction
checkout.session.completed→ Redis PUBLISH 'tenant.provision' JSON payload
customer.subscription.updatedprisma.subscription.update(status, currentPeriodEnd, cancelAtPeriodEnd)
customer.subscription.deletedprisma.subscription.update(status: 'CANCELED') → Redis PUBLISH 'tenant.deprovision' JSON payload

ddx_api_main tables used

TableColumns
plansid, tier, name, stripeProductId UNIQUE, stripePriceId, monthlyPriceCents, maxUsers, features, active
subscriptionid, stripeSubscriptionId UNIQUE, stripeCustomerId, status, stripeEventId UNIQUE, currentPeriodEnd, cancelAtPeriodEnd

Redis channels

ChannelConsumer
tenant.provisionprovisioner process (creates HAPI partition + MinIO buckets)
tenant.deprovisionprovisioner (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