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

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).

Controllers

Controller@Controller(...)Routes
PaymentSessionControllerappointment-payments/sessionsGET price-preview (quote before session) · POST / create pay-to-confirm session (@RequireRole(PATIENT)) · POST :appointmentId/defer defer payment (pay-later path)
StripeConnectControllerappointment-payments/stripe/connectGET start / GET callback — Stripe Connect OAuth onboarding
SumupOAuthControllerappointment-payments/admin/sumup/oauthGET start / GET callback — SumUp merchant OAuth onboarding
WebhooksControllerappointment-payments/webhooksPOST stripe-connect / POST stripe-standalone / POST sumup
PatientInvoiceControllerappointment-payments/invoicesGET :invoiceId/pdf — patient invoice PDF
AppointmentPaymentsAdminControllerappointment-payments/adminconfig (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

Security notes: PaymentSessionController routes are NEVER @Public/@RbacExempt; organizationId + patientId come from the gateway user, never the body. WebhooksController performs raw-body HMAC verification per provider; the PAID event finalizes the booking.

Providers (pluggable)

FileRole
providers/payment-provider.interface.tsPaymentProvider contract
providers/stripe.adapter.tsStripe Connect adapter
providers/sumup.adapter.tsSumUp adapter

Services

ServiceResponsibility
PaymentSessionServicepay-to-confirm session lifecycle
PaymentConfigServiceper-tenant provider config
AppointmentPricingServiceprice resolution
PaymentCredentialCipherServiceencrypt/decrypt per-tenant provider secrets
RefundServicerefund issuance
CatalogService / CatalogImageServicebookable-service catalog + images
SumupOAuthServiceSumUp OAuth token exchange

Crons

CronResponsibility
PaymentTimeoutCronexpire unpaid sessions
RefundSweepServicesweep 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.