10-cross-cuttingwave: W6filled2 citations

Appointment Payments — Patient Pay-to-Confirm (Stripe Connect + SumUp)

Audiences: developer, internal, clinical-buyer, investor

Appointment Payments — Patient Pay-to-Confirm (Stripe Connect + SumUp)

The B2C patient payment domain: a clinic collects an online payment from a patient to confirm an appointment booking. Multi-provider (Stripe Connect + SumUp OAuth), per-tenant provider credentials (encrypted at rest), a pay-to-confirm session state machine, patient invoices, and refunds. This is ddx-api/src/financial/appointment-payments/hard-isolated from the SaaS subscription payment domain (it never imports ddx-saas/ or financial/billing-saas/; the module comment states this explicitly).

Business Purpose

There are three distinct payment surfaces in the platform — do not conflate them:

DomainWho pays whomModule
SaaS subscriptionclinic operator → Dudoxx (B2B)ddx-saas service (extracted); ddx-api client = platform/saas-client/ — see SaaS Billing
Patient billing (invoicing)patient → clinic, ledger/invoicingfinancial/billing/ — see Billing
Appointment payments (this shard)patient → clinic, online pay-to-confirm a bookingfinancial/appointment-payments/

Appointment payments is the online-checkout layer that gates a booking: a patient books an appointment, pays online through the clinic's own connected payment provider (Stripe Connect account or SumUp merchant), and the PAID webhook finalizes the booking. Money flows to the clinic's connected account, not to Dudoxx — this is marketplace-style "Connect" money movement, distinct from the SaaS subscription rails.

Audiences

  • Clinical buyer: patients can be required to pay (or place a deposit) to confirm an appointment, reducing no-shows. Each clinic connects its own Stripe or SumUp account; funds land in the clinic's account directly.
  • Investor: demonstrates a marketplace payment integration (Stripe Connect OAuth + SumUp OAuth) with per-tenant provider isolation — a monetizable, no-show-reducing feature separate from the SaaS subscription revenue line.
  • Developer/partner: a pluggable PaymentProvider interface with two adapters (StripeAdapter, SumUpAdapter); per-tenant credentials encrypted via PaymentCredentialCipherService; a pay-to-confirm session state machine with timeout + refund-sweep crons.
  • Internal (ops/support): webhook URLs are surfaced via an admin endpoint (GET appointment-payments/admin/webhook-urls); a test-connection endpoint validates provider credentials before go-live.

Architecture

diagram
AppointmentPaymentsModule (ddx-api/src/financial/appointment-payments/)
│  imports AccountingModule, BillingModule, forwardRef(AppointmentsModule),
│  BookingFinalizerModule (leaf — breaks the old payments↔public-booking CJS cycle),
│  ProvisionalLoginTokenModule (PAID webhook binds new User → auto-login token)
│
├── PaymentSessionController      @Controller('appointment-payments/sessions')
│     GET  price-preview                   (quote before session)
│     POST /                               create pay-to-confirm session  (@RequireRole(PATIENT))
│     POST :appointmentId/defer            defer payment (pay-later path)
│       → NEVER @Public/@RbacExempt; organizationId + patientId from gateway user, never body
│
├── StripeConnectController       @Controller('appointment-payments/stripe/connect')
│     GET start / GET callback             Stripe Connect OAuth onboarding
│
├── SumupOAuthController          @Controller('appointment-payments/admin/sumup/oauth')
│     GET start / GET callback             SumUp merchant OAuth onboarding
│
├── WebhooksController            @Controller('appointment-payments/webhooks')
│     POST stripe-connect  / POST stripe-standalone  / POST sumup
│       → raw-body HMAC verification per provider; PAID event finalizes booking
│
├── PatientInvoiceController      @Controller('appointment-payments/invoices')
│     GET :invoiceId/pdf                   patient invoice PDF
│
└── AppointmentPaymentsAdminController  @Controller('appointment-payments/admin')
      config (GET/PATCH), bookable-services (GET/PATCH + price + delete),
      catalog (GET/PATCH + image up/down), invoices/:id/refund (POST),
      price-preview, test-connection/:provider, sumup/checkout/:id/status,
      webhook-urls

Providers (pluggable):
  providers/payment-provider.interface.ts   ← PaymentProvider contract
  providers/stripe.adapter.ts                ← Stripe Connect adapter
  providers/sumup.adapter.ts                 ← SumUp adapter

Services:
  PaymentSessionService        pay-to-confirm session lifecycle
  PaymentConfigService         per-tenant provider config
  AppointmentPricingService    price resolution
  PaymentCredentialCipherService  encrypt/decrypt per-tenant provider secrets
  RefundService                refund issuance
  CatalogService / CatalogImageService  bookable-service catalog + images
  SumupOAuthService            SumUp OAuth token exchange

Crons:
  PaymentTimeoutCron           expire unpaid sessions
  RefundSweepService           sweep eligible refunds

Tech Stack & Choices

ConcernChoiceRationale
Multi-providerPaymentProvider interface + Stripe/SumUp adaptersClinics choose their provider; adapter isolates the SDK surface
Money movementStripe Connect + SumUp merchantFunds land in the clinic's account, not Dudoxx's — marketplace model
Per-tenant credentialsPaymentCredentialCipherService (encrypt at rest)Each clinic's provider secret is tenant-scoped and never plaintext in DB
Webhook verificationraw-body HMAC per provider (WebhooksController)requires rawBody: true in main.ts; same discipline as any Stripe webhook
Booking finalizeleaf BookingFinalizerModule importavoids the payments↔public-booking eager-CJS require cycle that TDZ-crashed boot
Session expiryPaymentTimeoutCronunpaid pay-to-confirm sessions expire so slots free up
DBPrismaAccountingService (ddx_api_acc), via @Global() AccountingModulepayment/invoice rows live in the accounting DB

Isolation Invariant

appointment-payments.module.ts:1-8 states the hard rule: this module is "hard-isolated from the SaaS subscription/onboarding payment domain (never imports ddx-saas/ or financial/billing-saas/)." The two payment domains share no providers and no credentials. Do not cross-import.

Security Notes

  • PaymentSessionController create-session is @RequireRole(PATIENT) — never @Public/@RbacExempt. organizationId and patientId are resolved from the authenticated gateway user, never trusted from the request body (Invariant #6, payment-session.controller.ts:5-8).
  • Provider secrets are encrypted at rest via PaymentCredentialCipherService — never logged, never returned in config reads.
  • Webhook controllers verify raw-body HMAC per provider before acting.
    Appointment Payments — Patient Pay-to-Confirm (Stripe Connect + SumUp) — Dudoxx Docs | Dudoxx