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).
Controllers
| Controller | @Controller(...) | Routes |
|---|---|---|
PaymentSessionController | 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) |
StripeConnectController | appointment-payments/stripe/connect | GET start / GET callback — Stripe Connect OAuth onboarding |
SumupOAuthController | appointment-payments/admin/sumup/oauth | GET start / GET callback — SumUp merchant OAuth onboarding |
WebhooksController | appointment-payments/webhooks | POST stripe-connect / POST stripe-standalone / POST sumup |
PatientInvoiceController | appointment-payments/invoices | GET :invoiceId/pdf — patient invoice PDF |
AppointmentPaymentsAdminController | 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 |
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)
| File | Role |
|---|---|
providers/payment-provider.interface.ts | PaymentProvider contract |
providers/stripe.adapter.ts | Stripe Connect adapter |
providers/sumup.adapter.ts | SumUp adapter |
Services
| Service | Responsibility |
|---|---|
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
| Cron | Responsibility |
|---|---|
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.