|

Medspa integrations and boundaries

Payment adapters, webhooks, SMTP email, S3 storage, clinical assets, AI chat, MCP, and the event outbox in current source.

A PaymentProvider record or a configured environment variable describes a provider choice. It is not proof that a payment was collected, a refund reached a processor, or an email was delivered.

Payment adapters

features/integrations/payment/index.ts loads one of two code-owned adapters by the provider's adapter field; a database value cannot select an arbitrary module.

AdapterConfigurationCurrent use
manualNoneOperator-recorded cash at checkout (completeAppointmentCheckout). Onboarding seeds it as pp_manual_cash
stripeSTRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETAdapter for payment intents, refunds, and signed webhooks. Onboarding seeds it as pp_stripe

Operators enable, disable, and set the default provider at /dashboard/platform/integrations; PaymentProvider allows direct CRUD for roles with canManageIntegrations.

The money paths in current source are deliberately narrow:

  • completeAppointmentCheckout accepts only paymentMethod: "cash" and records it through the manual adapter.
  • processPayment returns "Use controlled appointment checkout" instead of charging.
  • processAppointmentDeposit is disabled until multi-payment allocation and cancellation reconciliation exist.
  • purchaseGiftCard is disabled until a settled tender and liability ledger exist; operators issue gift cards with issueGiftCard.
  • refundPayment only refunds manual appointment-deposit sessions; processor refunds are refused until a durable outbox and webhook reconciliation workflow exist.
  • reverseAppointmentSettlement validates its input and then returns a disabled result.

So the Stripe adapter exists in source, but no current customer or operator flow takes a card payment through it.

Payment webhooks

POST /api/payments/webhooks/[provider] accepts a provider code matching pp_..., rejects bodies over 1 MB, and passes the raw body and stripe-signature header to reconcilePaymentWebhook. Only providers with the stripe adapter are accepted. The Stripe adapter verifies the signature with STRIPE_WEBHOOK_SECRET and fails when it is not set. Each event is recorded in PaymentWebhookEvent under a provider-and-event replay key, so a replayed event is not applied twice.

Email

features/keystone/lib/mail.ts sends password reset emails, appointment request receipts from bookAppointment, and gift card emails, using SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, and SMTP_FROM. SMTP_STORE_LINK sets the link used in emails. Email counts as configured only when both SMTP_HOST and SMTP_FROM are set; the integrations page shows that status. There is no SMS provider. MessageTemplate and MessageLog store templates and send records, but there is no scheduled reminder sender.

Storage

Catalog and provider images (MedSpaImage) use the Keystone my_images storage with S3-compatible object storage configured by S3_BUCKET_NAME, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, and S3_ENDPOINT. URLs are signed with a five-minute expiry, and path-style addressing is chosen from the endpoint. All five variables are required at startup. An isolated local runtime (enabled by MEDSPA_LOCAL_RUNTIME_FILE, which must point to a private descriptor the app verifies against the running PostgreSQL process) stores images on disk under .runtime/medspa-goal/public-images and refuses to start if any S3, SMTP, Stripe, or AI provider variable is set.

Clinical photos and documents

The ClientAsset, PhotoConsent, and AssetAccessEvent lists and their mutations (createClinicalAssetIntent, markClinicalAssetReady, authorizeClinicalAssetAccess, releaseClinicalAsset, tombstoneClinicalAsset, grantPhotoConsent, withdrawPhotoConsent) exist. createClinicalAssetIntent records a quarantined asset with a private storage key and the CLINICAL_ASSET_KMS_KEY_ID reference (or UNCONFIGURED_FAIL_CLOSED), but no encrypted upload to object storage is wired, and there is no dashboard page for clinical files.

AI chat and MCP

The dashboard has an AI chat sidebar backed by /api/completion. It requires a signed-in user with canAccessDashboard. It uses OPENROUTER_API_KEY and OPENROUTER_MODEL (and optional OPENROUTER_MAX_TOKENS) for the shared key, or a key and model the user supplies for that request.

The MCP endpoint /api/mcp-transport/[transport] returns HTTP 503 with "MCP access is disabled until an owner-approved data and tool policy is in place." No MCP tools are exposed in current source. The completion route loads its tools from that endpoint, so the AI chat has no working tool connection while MCP is disabled.

Do not enable a generic GraphQL bridge for agents. Clinical records, consent, intake, and client contact data need a narrow tool list, data minimization, audit, and human confirmation before any agent access.

Event outbox

features/keystone/lifecycle.ts writes DomainEvent records from appointment, payment, and gift card workflows, with a replay policy that rejects a reused event key carrying different content. OutboxEvent exists with pending, processing, delivered, and dead_letter states, but current source has no worker that delivers outbox events to an external system.

Out of scope in current source

  • Card payments, online deposits, and online gift card sales.
  • SMS, push notifications, and scheduled reminders.
  • Calendar sync, telehealth, e-prescribing, lab, or EHR integration.
  • License verification with an issuing board.
  • Accounting, payroll, or tax-filing exports.
  • Embedding the booking widget: WidgetConfiguration has CRUD but no storefront embed consumes it.

On this page