Appointment Payments — Patient Pay-to-Confirm (Stripe Connect + SumUp)
Audiences: developer, internal, clinical-buyer, investor
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 importsddx-saas/orfinancial/billing-saas/; the module comment states this explicitly).
Business Purpose
There are three distinct payment surfaces in the platform — do not conflate them:
| Domain | Who pays whom | Module |
|---|---|---|
| SaaS subscription | clinic operator → Dudoxx (B2B) | ddx-saas service (extracted); ddx-api client = platform/saas-client/ — see SaaS Billing |
| Patient billing (invoicing) | patient → clinic, ledger/invoicing | financial/billing/ — see Billing |
| Appointment payments (this shard) | patient → clinic, online pay-to-confirm a booking | financial/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
PaymentProviderinterface with two adapters (StripeAdapter,SumUpAdapter); per-tenant credentials encrypted viaPaymentCredentialCipherService; 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)
│
├── 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
| Concern | Choice | Rationale |
|---|---|---|
| Multi-provider | PaymentProvider interface + Stripe/SumUp adapters | Clinics choose their provider; adapter isolates the SDK surface |
| Money movement | Stripe Connect + SumUp merchant | Funds land in the clinic's account, not Dudoxx's — marketplace model |
| Per-tenant credentials | PaymentCredentialCipherService (encrypt at rest) | Each clinic's provider secret is tenant-scoped and never plaintext in DB |
| Webhook verification | raw-body HMAC per provider (WebhooksController) | requires rawBody: true in main.ts; same discipline as any Stripe webhook |
| Booking finalize | leaf BookingFinalizerModule import | avoids the payments↔public-booking eager-CJS require cycle that TDZ-crashed boot |
| Session expiry | PaymentTimeoutCron | unpaid pay-to-confirm sessions expire so slots free up |
| DB | PrismaAccountingService (ddx_api_acc), via @Global() AccountingModule | payment/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
PaymentSessionControllercreate-session is@RequireRole(PATIENT)— never@Public/@RbacExempt.organizationIdandpatientIdare 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.
Related Topics
- SaaS Billing — Extracted to the ddx-saas Service — the B2B subscription domain; hard-isolated from this one.
- Billing — Invoices, Charge Items and Pricing — the clinic's patient invoicing/ledger (B2C), distinct from this online pay-to-confirm layer.
- Accounting — payment/invoice rows persist to
ddx_api_acc.