Implementation plan for giving dental clients a facility to integrate their own Stripe account, modelled on the GP booking app's existing partner-facing facility. Status: IMPLEMENTED (2026-08-18) by OpenCode — S1–S6 complete and verified live on
test-client-dental(commits69857b27,fedbbd3d,66d22c2dondevelop). The manuals Financial section stays parked until Stripe + ERPNext both land.
Decision (2026-08-18): deal with the two dev areas separately — (1) client-facing Stripe integration first, then (2) ERPNext Phase B (see dental_app_erpnext_plan). The Financial Processing & NHS Claims user guides stay parked until both areas are implemented and verified live. Full task brief is in the OpenCode handoff note (agents/handoff/from-hermes, 2026-08-18, Status: OPEN) — this page is the detailed reference for that work.
The GP booking app's partner facility lives in the partner module:
| Route | View | Purpose |
|---|---|---|
partner/stripe-config/ |
PartnerStripeConfigView (UpdateView) |
Manage Stripe keys for the practice |
partner/stripe-config/test-connection/ |
PartnerStripeTestConnectionView |
POSTs the secret key, calls stripe.Balance.retrieve() → account name, email, country, balance |
partner/stripe-config/auto-webhook/ |
PartnerStripeAutoWebhookView |
Creates the Stripe webhook endpoint for payment_intent.succeeded + payment_intent.payment_failed |
partner/stripe-transactions/ |
PartnerStripeTransactionListView |
Paginated PaymentTransaction history (date, patient, invoice, amount, status, Stripe ID) with filters |
PartnerStripeConfigForm (ModelForm over appointments.PaymentConfig) with a stripe_key_validator (accepts pk_live_, pk_test_, sk_live_, sk_test_, whsec_, acct_).onboarding.Practice.org_id matched to PartnerOrg.slug; each tenant has exactly one linked practice.PartnerUserManageMixin; GP wiki says ORG_ADMIN / PARTNER_REGISTERED_MANAGER / PARTNER_ADMIN / ADMIN.EncryptedTextField for secret keys, never echoed back to the frontend, all changes logged to the audit log.Models (dental/models.py):
DentalPaymentConfig — OneToOne to DentalPractice (related_name='payment_config'): stripe_secret_key (EncryptedTextField), stripe_publishable_key, stripe_webhook_secret (EncryptedTextField), stripe_connect_account_id, deposit_percentage (default 0), default_currency (default 'gbp'), is_active (default True).DentalTransaction — FK to DentalAppointment (related_name='transactions'): total_amount, deposit_paid (default 0), payment_status (PENDING / PARTIAL / PAID / REFUNDED), stripe_payment_intent_id, stripe_reference, timestamps.Payment plumbing (dental/payment.py):
get_stripe_client(practice) — reads practice.payment_config (i.e. DentalPaymentConfig), returns None unless stripe_secret_key set and is_active.create_payment_intent(transaction, practice) — creates a PaymentIntent, stores stripe_payment_intent_id.confirm_deposit(transaction, practice).handle_webhook_event(payload, sig_header, webhook_secret) — payment_intent.succeeded → transaction PAID + stripe_reference populated; payment_failed handling.reconcile_claim_payment(claim) — FP17 co-payment reconciliation (sets transaction PARTIAL/PAID, records patient_share / nhs_reimbursable_share).Webhook endpoint (dental/urls.py:120 → dental/webhooks.py):
POST /dental/webhooks/stripe/<practice_id>/ (webhooks.stripe_webhook, csrf_exempt) — resolves an active DentalPractice, reads practice.payment_config.stripe_webhook_secret, verifies the signature, dispatches to handle_webhook_event. Already working.Current "integration" (the gap): a practice's Stripe keys are set only at onboarding time via environment variables (scripts/onboard_dental_practice.py reads STRIPE_PUBLISHABLE_KEY / STRIPE_SECRET_KEY). There is no client-facing facility to configure, test, or manage their own Stripe account.
The dental repo already carries copies of the GP partner Stripe views, form, and template — but they are not adapted and would be inert:
partner/views.py ~line 2022: PartnerStripeConfigView, PartnerStripeTestConnectionView, PartnerStripeAutoWebhookViewpartner/forms.py: PartnerStripeConfigForm (with the key validator)partner/templates/partner/stripe_config_form.htmlpartner/urls.py:79-81 (config / test-connection / auto-webhook — no stripe-transactions/ route)dsp_clinic/urls.py:53 (path('partner/', include('partner.urls')))Why they are inert — the gaps to close:
appointments.models_payment.PaymentConfig (GP model). The dental payment flow reads dental.DentalPaymentConfig. Keys entered via the UI today would never be used by get_stripe_client / the webhook.DentalPractice has no org_id / PartnerOrg link (unlike GP's onboarding.Practice.org_id). The partner view's practice lookup must be adapted to resolve the practice from the tenant context, not org_id matching.https://{subdomain}.gp.veripath.co.uk/webhooks/stripe/ — wrong domain and wrong path. Dental's real webhook is https://{subdomain}.dental.veripath.co.uk/dental/webhooks/stripe/<practice_id>/.stripe-transactions/ (PaymentTransaction). The dental equivalent should list DentalTransaction rows (date, patient, appointment type, amount, deposit, payment status, Stripe reference) with filters.All code changes on develop, committed per logical unit, verified live before claiming done (remember: restart dental_app after code changes — running process ≠ code on disk).
S1. Adapt the partner views/form/template to DentalPaymentConfig
PartnerStripeConfigView.get_object() to resolve the practice from the tenant context (the pattern used elsewhere in the dental app for practice-scoped lookups) and get-or-create the DentalPaymentConfig.PartnerStripeConfigForm to model = DentalPaymentConfig (keep the key validator; keep the "current key set" placeholder behaviour so secrets are never echoed).log_partner_action with stripe_config_updated).S2. Fix the auto-webhook URL
https://{subdomain}.dental.veripath.co.uk/dental/webhooks/stripe/<practice_id>/ (confirm the exact tenant subdomain pattern with the live test tenant) and create the endpoint for payment_intent.succeeded + payment_intent.payment_failed.S3. Add the dental transactions history view
partner/stripe-transactions/ (or a dental-appropriate route) listing DentalTransaction: date, patient, appointment type, total, deposit, payment status badge, Stripe reference; paginated + filtered (status, date range).S4. Security
EncryptedTextField at rest; never returned in plaintext; webhook secret handled exactly like the secret key; changes audit-logged. No secrets in code or docs.S5. Role gating
PartnerUserManageMixin chain.S6. End-to-end verification (live, on the test-client-dental tenant)
DentalPaymentConfig row is written (DB read), not an appointments.PaymentConfig row./dental/webhooks/stripe/<practice_id>/ → transaction becomes PAID with stripe_reference populated.DentalTransaction rows.infrastructure/SIAAS/gp-booking-payments/stripe-setup but with /dental/webhooks/stripe/<practice_id>/ and the partner UI flow instead of Django admin).dental_onboarding / onboarding docs: keys now configured by the client via the UI, not env vars at onboarding.