Commerce Module — Feature Spec¶
Status: DRAFT (2026-05-13) — pending SD review
Author: Claude (Opus, planning role)
Tracking issue: none yet — abstraction work, no upstream issue prerequisite. Open an umbrella issue at PR-1 merge time.
ADR scope: ADR-0018 §Phase 4 (Payments + SLA) — broadens the §4 scope from "Stripe + Razorpay endpoints" to a unified commerce substrate (payments, subscriptions, ledger, settlement, commission, refunds, receipts).
Pattern reference: mirrors the app/services/matching/ registry + provider-abstraction pattern. The PaymentProvider Protocol mirrors the Normalizer/Parameter shape — a frozen-config provider resolved via a get_provider(name) lookup, with no cross-domain imports.
Related specs:
- docs/specs/commission-ledger-feature.md — D4 facilitator commission ledger (shipped 2026-05-03). Commerce subsumes the vendor commission case (Stripe Connect / Razorpay Route); facilitator-commission stays its own table per its own state machine (no rewrite — facilitator commissions are platform-side payouts to internal partners, commerce ledger is the universal money-movement record).
- docs/specs/multicurrency-feature.md — currency conversion service; commerce calls into it at settlement time.
- docs/specs/transportation-tier-feature.md — Phase 7 transport vendors are commerce's first concrete settlement consumer.
Clinical review: N/A. No patient-facing prose surfaces beyond receipt-template strings; receipt copy goes through the standard voice rules (config/voice_rules.yaml) but does not contain clinical content.
Land target: Strangler migration (Q2 below). New flows ship via app/services/commerce/; existing app/services/payments/ stays operational. Migration window ~6-10 PRs. Flagsmith-gated rollout: commerce_module_enabled (default off; dual env per feedback_flagsmith_dual_env.md).
Don't merge anything in PR-1a or PR-1b (schema scaffolding) without code review + architecture review. Ledger is append-only double-entry; once rows land we cannot retroactively fix sign conventions, currency-unit conventions, or the FK shape without a full migration. The shape locks at PR-1a.
1. Goals + non-goals¶
1.1 Goals (MVP)¶
| # | Goal |
|---|---|
| G1 | Single abstraction for "logical payment" across Stripe and Razorpay — callers in domains (quotes, consultations, transport, recovery) work with payment_intent_id, not provider-specific IDs. |
| G2 | Append-only double-entry ledger (commerce_ledger_entries) as universal money-event record. Every state change in payment / refund / settlement writes ledger rows in the same transaction as the mutation. |
| G3 | Subscription primitive (commerce_subscriptions) wraps Stripe Subscriptions + Razorpay Subscriptions. Interface only at PR scope (Q1) — first concrete caller defines runtime requirements. |
| G4 | Platform→vendor settlement abstraction (commerce_settlements) over Stripe Connect (destination charges preferred) + Razorpay Route. Powers transport vendor payouts, recovery vendor payouts, provider commission disbursement. |
| G5 | Per-vendor commission schedule (commerce_commission_schedules) — flat / percentage / tiered / per-transaction override. Versioned with effective dates. |
| G6 | Universal refund flow (commerce_refunds) — partial + full, reason taxonomy + actor capture. Emits commerce.refund.completed event; downstream domains listen, commerce does NOT import them. |
| G7 | Receipt generator (commerce_receipts) — cross-provider PDF format, R2 storage, send via existing notify service. Same template for Stripe + Razorpay charges. |
| G8 | Unified webhook router at /api/v1/commerce/webhooks/{provider} — delegates to provider-specific verifier + parser, writes ledger entries. Existing /api/v1/stripe/webhooks etc. remain functional during migration (Q7). |
| G9 | Multi-currency: storage in smallest unit + ISO 4217. USD cents (Stripe), INR paise (Razorpay) abstracted at the provider boundary. FX conversion at settlement time captured as a ledger row (fx_spread_ledger_entry). |
| G10 | Idempotency: every external API call gets a deterministic key derived from internal payment_intent_id + operation name. Stripe Idempotency-Key + Razorpay idempotency_key. |
| G11 | Admin oversight surface — 8 admin screens defined in §11. Ledger, payment intent, refund, settlement, commission, dispute, subscription, webhook log. |
| G12 | All commerce code respects CLAUDE.md ground rules: tenant_id everywhere, repository pattern, modular monolith, no cross-domain imports, feature-flagged. |
1.2 Non-goals (explicitly deferred)¶
- Subscription implementation with first concrete caller. Q1 default is interface-only — the abstraction shape locks now; the implementation ships when the first caller (recovery accommodation / provider listing fees) materialises. No live Stripe Subscription is created in PR-1a..PR-8.
- Replacing the existing Stripe code in
app/services/payments/. Strangler migration (Q2): existing flows keep working, new flows opt into commerce. Hard cut-over after consultation-charges, quote-acceptance, and transport-payouts are all on commerce — separate retirement PR post-migration. - Tax computation. Q8 default is pass-through. Commerce captures a
tax_amount_minor_unitsfield on each charge; computation done by a futuretax_service(out of scope this spec). - Multi-leg payments / split capture at MVP. Each
commerce_payment_intentrow is single-buyer→single-platform. Vendor splits happen at settlement time via Stripe Connect destination charges (one charge, one transfer in same operation). - Currency hedging / forward contracts. All FX through
currency_servicespot rates. No commerce-side hedging. - Reconciliation against provider statements (Stripe Sigma / Razorpay reports). Tracked as a follow-up: nightly job diff ledger vs provider records. Out of scope this spec — open issue at PR-7.
- Webhook event-to-handler dispatch table.
- Refund-reason taxonomy localisation.
- Chargeback / dispute auto-response. Admin acknowledges + adds notes (§11.6); machine-driven evidence submission to providers is post-MVP.
- Per-tenant pricing experiments. Tracked via
commerce_commission_schedules.metadata_jsonfor now; A/B framework deferred. - Webhook fan-out to QStash for downstream domain consumers in PR-1a..PR-4. Q3 default ships in PR-5b once events are stable (synchronous in-process emission first; QStash later).
- Sanctions screening + OFAC/EU restricted-list checks. Delegated to Stripe Radar / Stripe Identity + Razorpay's onboarding KYC. Commerce trusts the provider's KYC. Stripe DPA + Razorpay DPA on file. Document this dependency in ADR-0018.
2. Motivation + rationale¶
2.1 Why build now, not retrofit later¶
SD's argument (captured verbatim from the prompt, lightly edited for spec format):
- Subscription billing is fundamentally different from one-time. You cannot retrofit subscription semantics — recurring intervals, plan changes, proration, dunning, cancel-at-period-end — onto a one-time
payment_intentabstraction without leakage. Either the one-time path grows subscription branches (every domain getsif recurring: ...checks) or the subscription path becomes a second abstraction next to the first. Build both behind the same protocol at the start. - Multi-party flows need a ledger primitive. Curaway is a marketplace: patient → platform → vendor with commission. Without a ledger, every domain re-invents the "we owe vendor X cents, we keep Y cents as commission, here's a receipt the patient sees" math. With a ledger, one append-only table records every cent movement; every domain reads the ledger to know what's owed.
- Stripe Connect and Razorpay Route are the right substrates. Both providers offer connected-account / linked-account models with destination charges (Stripe) and Route splits (Razorpay). Abstracting at the right level —
transfer_to_vendor(vendor_id, amount_minor_units, currency)— means we can swap providers per region (UAE → Tap, Brazil → Pagar.me) without changing callers. - Cross-cutting consumers force the substrate. Transportation tier (#713 /
transportation-tier-feature.md) needs vendor payouts. Recovery accommodation may need recurring billing. Provider listing fees (post-MVP) will need subscriptions. If we build the substrate after the first consumer, the first consumer's needs leak into every later one. Build the substrate before consumer #1 hits production. Consumer #1 is transport; that build is staged behind Phase 7, so the timing is right.
2.2 What this is NOT¶
- Not a rewrite of
app/services/payments/. That code stays. Commerce is parallel. - Not a new payment provider. Stripe + Razorpay remain. Commerce is the abstraction layer above them.
- Not a tax engine. See §1.2.
- Not a reconciliation tool. See §1.2.
- Not a replacement for facilitator-commission ledger. See §1 header.
2.3 What changes for callers¶
| Before | After |
|---|---|
from app.services.payments import get_provider; provider.authorize(billing_context) |
from app.services.commerce import payment_intent_service; await payment_intent_service.create_intent(amount, currency, vendor_id=None) |
Caller picks provider via routing.pick_payment_provider(...) |
Commerce resolves provider internally. Caller passes intent + currency + vendor; commerce routes. |
Webhook handlers per provider in app/routers/stripe_webhooks.py, app/routers/razorpay_webhooks.py |
Single /api/v1/commerce/webhooks/{provider} router; provider verifier returns canonical event type; commerce writes ledger. |
| No multi-party split. Patient pays platform; coordinator manually arranges vendor payouts. | payment_intent declares vendor_id; commerce uses Stripe Connect destination charge or Razorpay Route at capture. |
| No subscription support. | subscription_service.create_subscription(...) (interface — see Q1). |
| Refunds go through provider-specific code in callers. | refund_service.refund(payment_intent_id, amount, reason, actor) — emits commerce.refund.completed. |
| No ledger. Reconciliation by hand against Stripe dashboard. | Every state change writes a ledger row. Admin views ledger directly. |
3. Module structure¶
app/services/commerce/ — 7 files matching SD's proposed shape, with module-level __init__.py exposing the public API.
| File | Responsibilities | Calls into | Calls out from |
|---|---|---|---|
__init__.py |
Exposes payment_intent_service, subscription_service, refund_service, settlement_service, commission_service, receipts_service. Defines PaymentProvider Protocol + get_provider(name) registry. |
— | Imported by routers + (after migration) by domain services. |
payment_intent.py |
PaymentIntentService — creates / authorises / captures / cancels logical payments. One row in commerce_payment_intents. Resolves provider via provider_registry. Writes ledger entries on every state change. |
provider_registry, ledger, commission, repositories.commerce_payment_intents |
None (this is the entry point for callers) |
subscription.py |
SubscriptionService — wraps Stripe Subscriptions + Razorpay Subscriptions. Q1 default: interface only; create_subscription raises NotImplementedError("commerce.subscription deferred — see Q1") until first caller wires up. State sync via webhook fan-out. |
provider_registry, ledger, repositories.commerce_subscriptions |
None |
ledger.py |
LedgerService — append-only writer. Every helper (record_charge, record_refund, record_settlement, record_fx_spread) creates 2 rows (debit + credit) and asserts they balance per currency. Read helpers for admin search/filter. |
repositories.commerce_ledger_entries |
None |
settlement.py |
SettlementService — platform→vendor payouts. Prefers Stripe Connect destination charges (split at capture, no separate transfer). Falls back to per-vendor transfer calls when destination charges unavailable. Razorpay Route equivalent. Captures FX spread as ledger row if vendor currency != patient currency. |
provider_registry, ledger, commission, repositories.commerce_settlements |
None |
commission.py |
CommissionService — resolves the active schedule for a vendor + transaction, computes platform fee (flat / percentage / tiered), returns (platform_fee_minor_units, vendor_amount_minor_units). Versioned schedules per effective_from / effective_to. Per-transaction override via payment_intent.commission_override_minor_units. |
repositories.commerce_commission_schedules |
Called by payment_intent + settlement |
refund.py |
RefundService — partial + full refunds. Validates against captured amount. Calls provider refund method. Writes ledger entries (reverses original charge legs). Emits commerce.refund.completed event (sync in-process to start; QStash post-Q3). |
provider_registry, ledger, repositories.commerce_refunds |
Emits event (no direct domain imports) |
receipts.py |
ReceiptsService — generates PDF via Jinja template (config/receipt_templates/), uploads to R2, logs send via notify service. Same template for Stripe + Razorpay charges. Stores reference in commerce_receipts. |
repositories.commerce_receipts, r2_client, notify_service |
None |
3.1 Provider abstraction¶
PaymentProvider Protocol — every concrete provider (Stripe, Razorpay, future Tap / Pagar.me) implements:
class PaymentProvider(Protocol):
name: ProviderName # 'stripe' | 'razorpay'
minor_unit_per_major: dict[str, int] # {"USD": 100, "INR": 100} — paise+cents both 100
supported_currencies: frozenset[str]
supports_destination_charges: bool
supports_subscriptions: bool
async def create_payment_intent(
self, *, amount_minor_units: int, currency: str,
idempotency_key: str, vendor_account_id: str | None,
commission_minor_units: int | None,
metadata: dict[str, str],
) -> ProviderPaymentIntent: ...
async def capture_payment_intent(
self, *, provider_intent_id: str, idempotency_key: str,
) -> ProviderPaymentIntent: ...
async def cancel_payment_intent(
self, *, provider_intent_id: str, idempotency_key: str,
) -> ProviderPaymentIntent: ...
async def create_subscription(
self, *, customer_id: str, plan_id: str, idempotency_key: str,
trial_days: int | None, metadata: dict[str, str],
) -> ProviderSubscription: ... # NotImplemented at MVP per Q1
async def refund(
self, *, provider_intent_id: str, amount_minor_units: int | None,
idempotency_key: str, reason: str, metadata: dict[str, str],
) -> ProviderRefund: ...
async def transfer_to_vendor(
self, *, vendor_account_id: str, amount_minor_units: int,
currency: str, idempotency_key: str, metadata: dict[str, str],
) -> ProviderTransfer: ...
async def fetch_account_capabilities(
self, *, vendor_account_id: str,
) -> ProviderAccountCapabilities: ... # returns charges_enabled / payouts_enabled / reasons
async def webhook_verify(
self, payload: bytes, signature_header: str, *, tolerance_seconds: int = 300
) -> bool: ...
def webhook_parse(self, payload: bytes) -> CommerceWebhookEvent: ...
async def fetch_transfer(
self, *, provider_transfer_id: str,
) -> ProviderTransfer: ... # used by §11.4 settlement override validation
Registry pattern mirrors app/services/matching/registry.py — module-level _PROVIDER_REGISTRY: dict[ProviderName, type[PaymentProvider]] = {} populated via @register_provider("stripe") decorator. Lazy SDK imports inside get_provider(name) to avoid loading Razorpay SDK when only Stripe is in use (mirrors app/services/payments/__init__.py:get_provider).
3.2 What does NOT live in commerce/¶
- Patient identity, FHIR, consent, quote acceptance, consultation lifecycle. Owned by their respective domains. Commerce reads via service calls only.
- Notification rendering. Notify service owns the email/SMS templates; commerce passes a
CommerceReceiptPayloadDTO. - Currency rate caching.
currency_serviceowns. Commerce callscurrency_service.convert(amount_minor_units, from_currency, to_currency). - Provider routing rules. Existing
app/services/payments/routing.py:pick_payment_providercontinues to be the source of routing logic. Commerce wraps it; routing module stays where it is (per Q7 strangler migration).
3.3 Domain boundary¶
Per CLAUDE.md "Domain Boundaries" — commerce reads from no other domain via internal imports. It is called by:
app/services/teleconsultation_payments.py(current Stripe caller — migrates in PR-6)app/routers/provider_quotes.py:accept_quote(current Stripe caller — migrates in PR-6)app/services/transport_*(Phase 7) — uses commerce from day oneapp/services/recovery_*(subscription consumer when activated)- Admin routers for the 8 oversight screens (§11)
Commerce emits events (commerce.payment_intent.captured, commerce.refund.completed, etc.) that downstream domains listen for. No direct calls from commerce into domain services.
3.4 External call budgets¶
Every commerce → Stripe / Razorpay / R2 / currency_service HTTP call uses an explicit httpx.AsyncClient with timeout=httpx.Timeout(connect=2.0, read=8.0, write=2.0, pool=10.0). Provider adapters share a single AsyncClient instance per process (lifecycle managed in FastAPI lifespan). Aggregate timeout budget per request ≤10s — webhook handler that exceeds budget returns 5xx, Stripe retries.
Neon connection pool: commerce service methods use ONLY the existing async_session_factory (PgBouncer-fronted). Two-tx refund pattern releases the connection during the HTTP call → pool can size to N/2 of peak concurrent refunds. Document peak refund expectation in PR-5b acceptance: load test with 50 concurrent refunds, no pool exhaustion.
3.5 Provider SDK pinning¶
SDK version pinning (declared in requirements.txt and enforced in PR-3 / PR-4 acceptance):
- stripe ~= 11.0 (or pin exact major at PR-3 time; reject auto-upgrade across major boundaries)
- razorpay ~= 1.4 (or pin exact major)
API version pinning:
- Stripe: stripe.api_version = settings.STRIPE_API_VERSION (env var, default a known-good Stripe API date like '2024-12-18.acacia'). Upgrade via Flagsmith flag flip + canary tenant first.
- Razorpay: no pinned API version concept; major version upgrades reviewed via ADR.
4. Data model¶
All 7 tables are tenant-scoped. RLS policies follow the recovery_provider_profiles template — tenant_isolation policy + curaway_app SELECT/INSERT/UPDATE grants. All FK target tables exist today (tenants, provider_quotes, consultation_charges, providers, cases).
metadata_json allow-list per table. Each table's metadata_json has a per-table allow-list of permitted keys. Repository validators (app/repositories/commerce_*.py) reject inserts/updates that contain keys outside the allow-list. Allow-lists defined in config/commerce_metadata_schema.yaml.
Initial entries:
commerce_payment_intents:
allowed_keys: [bundled_intent_ids, fx_link_id, case_access_exempt_reason, correlation_id, replaces_subscription_id, customer_country, patient_country_iso]
enum_constraints:
case_access_exempt_reason: [compliance_review, regulator_request, duplicate_charge_outside_case, merchant_dispute_settlement]
# Free-text rejected at router (HTTP 422) and at repository validator. See §10.3 step 1.
commerce_ledger_entries:
allowed_keys: [fx_link_id, reverses_pair_id, correlation_id, original_provider_event_id]
commerce_refunds:
allowed_keys: [correlation_id, dispute_id, fx_link_id]
commerce_settlements:
allowed_keys: [bundled_intent_ids, fx_link_id, vendor_account_status, correlation_id]
commerce_subscriptions:
allowed_keys: [replaces_subscription_id, plan_change_at, correlation_id]
commerce_commission_schedules:
allowed_keys: [approval_notes_summary]
commerce_receipts:
allowed_keys: [template_inputs_signature, correlation_id]
No patient name, email, address, MRN, or clinical data permitted in any allow-list. Tests in PR-1a verify the validator rejects forbidden keys.
actor_id NOT NULL with system sentinels. Every commerce table's actor_id column is NOT NULL. Admin-initiated actions use the full Clerk user ID. System actions (webhooks, schedulers, migrations) use sentinel actors:
- system:webhook:stripe
- system:webhook:razorpay
- system:scheduler:settlement_sweep
- system:scheduler:subscription_invoice_sweep
- system:migration (only during PR-1a/1b backfill)
Aligns with coding-principles.md §5 — log full IDs, no truncation.
4.1 commerce_payment_intents — one row per logical payment¶
CREATE TABLE commerce_payment_intents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
-- Source domain reference (sparse — only one of these will be set per row)
source_domain VARCHAR(40) NOT NULL,
-- 'consultation_charge' | 'quote_acceptance' | 'transport_booking' | 'recovery_package' | 'provider_listing_fee' | 'admin_adhoc'
source_id VARCHAR(36) NOT NULL,
-- FK target depends on source_domain; enforced at service layer
-- Money (CLAUDE.md: minor units + ISO 4217)
amount_minor_units BIGINT NOT NULL CHECK (amount_minor_units > 0 AND amount_minor_units <= 10000000000),
-- Max ~$100M USD-equivalent. Larger transactions require manual ADR review.
currency VARCHAR(3) NOT NULL,
-- ISO 4217: USD, INR, EUR, GBP, AED
-- TODO(PR-1a): add CHECK (currency IN ('USD','INR','EUR','GBP','AED')); extend list via migration as new corridors land
-- Commission split (resolved at intent-creation time, applied at capture)
commission_minor_units BIGINT, -- platform's cut
vendor_account_id VARCHAR(255), -- Stripe Connect acct_xxx / Razorpay sub-merchant acc_xxx
vendor_currency VARCHAR(3), -- if different from `currency`, FX happens at capture
commission_schedule_id UUID REFERENCES commerce_commission_schedules(id),
commission_override_minor_units BIGINT, -- per-transaction admin override
-- Provider snapshot
provider VARCHAR(20) NOT NULL CHECK (provider IN ('stripe', 'razorpay')),
provider_intent_id VARCHAR(255), -- pi_xxx / order_xxx; NULL until provider call succeeds
-- Idempotency
internal_idempotency_key VARCHAR(64) NOT NULL UNIQUE, -- deterministic: hash(source_domain + source_id + 'create')
-- State machine
status VARCHAR(30) NOT NULL DEFAULT 'pending',
-- 'pending' | 'authorized' | 'captured' | 'settled' | 'refunded' | 'partially_refunded' | 'cancelled' | 'failed'
-- Audit
actor_id VARCHAR(255) NOT NULL, -- never null; system actions use 'system:webhook:stripe' / 'system:scheduler:settlement' (full id; see coding-principles.md §5)
regulatory_flag VARCHAR(40), -- 'high_value' (>=$10K USD eq), 'cross_border_aml_review', NULL
-- TODO(PR-1a): regulatory_flag CHECK constraint with closed taxonomy
-- TODO(post-MVP): consumer workflow for regulatory_flag — currently flag-only; add downstream reporting integration when scaling
metadata_json JSONB DEFAULT '{}'::jsonb,
raw_provider_payload JSONB, -- last response from provider, for debugging (passes through `redact_provider_payload` first — see §6.1.2)
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
authorized_at TIMESTAMPTZ,
captured_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_cpi_tenant_status ON commerce_payment_intents(tenant_id, status);
CREATE INDEX ix_cpi_source ON commerce_payment_intents(source_domain, source_id);
CREATE INDEX ix_cpi_provider_intent ON commerce_payment_intents(provider, provider_intent_id);
CREATE INDEX ix_cpi_created ON commerce_payment_intents(created_at DESC);
State transitions: pending → authorized → captured → (settled | refunded | partially_refunded). Terminal: cancelled, failed. Validated in PaymentIntentService.transition(...) (state machine pattern from case_lifecycle.py).
'partially_refunded'is computed as(captured AND EXISTS(refund WHERE intent_id=this AND status='succeeded') AND total_refunded < captured_amount). Do NOT store oncommerce_payment_intents.status— derive fromcommerce_refundsaggregation in the read path. Mark the state value'partially_refunded'as deprecated / view-only — admin UI shows it but DB persistence uses'captured'until fully refunded →'refunded'. Lint at PR-5a: service must NEVER write'partially_refunded'directly.
4.2 commerce_ledger_entries — append-only double-entry¶
CREATE TABLE commerce_ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
-- Linkage
payment_intent_id UUID REFERENCES commerce_payment_intents(id) ON DELETE RESTRICT,
refund_id UUID REFERENCES commerce_refunds(id) ON DELETE RESTRICT,
settlement_id UUID REFERENCES commerce_settlements(id) ON DELETE RESTRICT,
-- Double-entry semantics
entry_pair_id UUID NOT NULL,
-- Two rows per event share entry_pair_id; one debit one credit; sum to zero per currency
account_type VARCHAR(40) NOT NULL,
-- 'platform_revenue' | 'platform_pass_through' | 'vendor_payable' | 'patient_charge' | 'fx_spread' | 'tax_payable' | 'refund_payable'
direction VARCHAR(10) NOT NULL CHECK (direction IN ('debit', 'credit')),
-- Money
amount_minor_units BIGINT NOT NULL,
-- Always positive; direction defines sign
currency VARCHAR(3) NOT NULL,
-- Classification
entry_type VARCHAR(40) NOT NULL,
-- 'charge' | 'capture' | 'refund' | 'settlement' | 'commission' | 'fx_spread' | 'tax' | 'dispute_hold' | 'reversal'
description TEXT,
-- Subject identification (for GDPR erasure)
subject_patient_id UUID, -- the patient this entry "belongs to" for erasure scope; nullable for platform-only entries (fees, fx_spread)
-- Audit
actor_id VARCHAR(255) NOT NULL, -- never null; system actions use 'system:webhook:stripe' / 'system:scheduler:settlement'
metadata_json JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_cle_tenant_created ON commerce_ledger_entries(tenant_id, created_at DESC);
CREATE INDEX ix_cle_subject_patient ON commerce_ledger_entries(subject_patient_id)
WHERE subject_patient_id IS NOT NULL;
CREATE INDEX ix_cle_payment_intent ON commerce_ledger_entries(payment_intent_id);
CREATE INDEX ix_cle_refund ON commerce_ledger_entries(refund_id);
CREATE INDEX ix_cle_settlement ON commerce_ledger_entries(settlement_id);
CREATE INDEX ix_cle_entry_pair ON commerce_ledger_entries(entry_pair_id);
CREATE INDEX ix_cle_entry_type ON commerce_ledger_entries(entry_type);
CREATE UNIQUE INDEX ux_cle_pair_account_direction ON commerce_ledger_entries(entry_pair_id, account_type, direction);
The (entry_pair_id, account_type, direction) triple is unique — supports ON CONFLICT DO NOTHING for idempotent ledger writes on webhook replay.
Every entry tied to a patient charge sets subject_patient_id; platform-internal entries (fx_spread, commission to platform) leave it NULL. The subject_patient_id column scopes ledger rows to a patient for GDPR Article 17 erasure (see §15.1).
Append-only enforcement: no UPDATE / DELETE grants for curaway_app. Reversals are new rows (entry_type='reversal', references original entry_pair_id via metadata_json.reverses_pair_id).
Balance invariant: within a single entry_pair_id, sum of direction='credit' amounts equals sum of direction='debit' amounts, per currency. Enforced at the service layer (assertion in LedgerService._write_pair) and verified by a CI scan over fixture-loaded ledgers.
description is FREE-text but the service-layer validator constrains it to slug-only format ^[a-z0-9_:.-]{0,200}$. Patient-readable copy belongs in commerce_receipts, not the ledger. Enforced in LedgerService._write_pair.
Cross-currency events (FX spread):
- A capture in USD that settles a vendor in INR produces three pair_ids:
1. entry_pair_id=A: patient_charge debit USD, platform_pass_through credit USD
2. entry_pair_id=B: platform_pass_through debit USD, vendor_payable credit INR (logically — actually two pairs because cross-currency)
3. entry_pair_id=C: fx_spread debit/credit pair capturing the rate-of-day delta
- The exact pair shape is deferred to PR-1a schema review; what matters at the spec level is that every cross-currency operation produces an fx_spread ledger row.
Locked pair shape (PR-1a): every ledger pair is SINGLE-currency. Cross-currency events produce TWO single-currency pairs linked via metadata_json.fx_link_id (a shared UUID across the two pairs). The fx_spread pair is a third single-currency pair in the destination currency, also sharing the fx_link_id.
Additional ledger index suggestion (PR-1a) — supports admin grouping by FX flow:
CREATE INDEX ix_cle_fx_link ON commerce_ledger_entries((metadata_json->>'fx_link_id'))
WHERE metadata_json ? 'fx_link_id';
4.2.1 Partitioning strategy¶
commerce_ledger_entries is the highest-volume table (3-7 rows per payment event). At 100x scale (~1M rows/year), index maintenance + writes degrade. PR-1a creates the table with PARTITION BY RANGE (created_at) + initial monthly partitions for the current month + next 2 months. A pg_cron job (or QStash scheduler — choose at PR-1a time) provisions next-month partition 7 days ahead. Retention: ALL partitions kept indefinitely (append-only ledger is audit primary). Indexes per §4.2 are local-to-partition.
Initial migration:
CREATE TABLE commerce_ledger_entries (
...
) PARTITION BY RANGE (created_at);
CREATE TABLE commerce_ledger_entries_y2026m05
PARTITION OF commerce_ledger_entries
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- (repeat for next 2 months)
4.3 commerce_subscriptions — recurring billing wrapper¶
CREATE TABLE commerce_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
-- Subscriber + product
customer_external_id VARCHAR(255) NOT NULL, -- our customer reference (patient_id, provider_id, etc.)
customer_domain VARCHAR(40) NOT NULL, -- 'patient' | 'provider' | 'admin'
plan_code VARCHAR(60) NOT NULL, -- 'recovery_accommodation_monthly' | 'provider_listing_basic'
amount_minor_units BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL,
interval VARCHAR(20) NOT NULL, -- 'day' | 'week' | 'month' | 'year'
interval_count INTEGER NOT NULL DEFAULT 1,
-- Provider linkage
provider VARCHAR(20) NOT NULL CHECK (provider IN ('stripe', 'razorpay')),
provider_subscription_id VARCHAR(255),
provider_customer_id VARCHAR(255),
-- State machine
status VARCHAR(30) NOT NULL DEFAULT 'pending',
-- 'pending' | 'trialing' | 'active' | 'paused' | 'past_due' | 'cancelled' | 'expired'
trial_ends_at TIMESTAMPTZ,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT false,
cancelled_at TIMESTAMPTZ,
-- Idempotency + audit
internal_idempotency_key VARCHAR(64) NOT NULL UNIQUE,
actor_id VARCHAR(255) NOT NULL, -- never null; system actions use 'system:webhook:stripe' / 'system:scheduler:settlement'
metadata_json JSONB DEFAULT '{}'::jsonb,
raw_provider_payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_csub_tenant_status ON commerce_subscriptions(tenant_id, status);
CREATE INDEX ix_csub_customer ON commerce_subscriptions(customer_domain, customer_external_id);
CREATE INDEX ix_csub_period_end ON commerce_subscriptions(current_period_end);
Per Q1, the table ships at PR-1b but SubscriptionService.create_subscription raises NotImplementedError until first caller activates.
State transitions:
- pending → trialing (on Stripe customer.subscription.trial_will_end precursor; first invoice succeeds with trial active)
- pending → active (on first successful invoice, no trial)
- pending → cancelled (admin cancels before first invoice, OR first-invoice failure terminal)
- trialing → active (trial ends, first paid invoice succeeds)
- trialing → cancelled (admin or customer cancels during trial)
- active → past_due (invoice failure; retries pending)
- past_due → active (retry succeeds)
- past_due → cancelled (retry retries exhausted)
- active → paused (admin pauses)
- paused → active (admin resumes)
- active → cancelled (admin or customer cancels — immediate or cancel_at_period_end=true)
- cancelled → expired (period ends after cancellation)
Plan changes: mid-cycle plan changes create a NEW commerce_subscriptions row with metadata_json.replaces_subscription_id = <old_id>, and the old row's status='cancelled'. Pro-rata invoicing handled by Stripe Subscription Schedule; commerce reflects the resulting invoices via webhook.
4.4 commerce_settlements — platform→vendor payouts¶
CREATE TABLE commerce_settlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
-- Source payment(s)
payment_intent_id UUID REFERENCES commerce_payment_intents(id) ON DELETE RESTRICT,
-- For destination-charge + razorpay-route settlements (MVP default), the `payment_intent_id` FK is single-valued.
-- For batched / nightly settlements (mechanism IN ('batched', 'manual')), the FK is NULL and the join table
-- `commerce_settlement_intents` records which intents are bundled (see join table DDL below).
-- Vendor
vendor_provider_id UUID REFERENCES providers(id),
-- NULL for facilitator payouts (those have their own commission table — see §1 header)
vendor_account_id VARCHAR(255) NOT NULL, -- Stripe Connect acct_xxx / Razorpay sub-merchant
-- Money
gross_amount_minor_units BIGINT NOT NULL,
platform_fee_minor_units BIGINT NOT NULL,
vendor_net_minor_units BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL,
fx_rate NUMERIC(18,8), -- if patient currency != vendor currency
fx_source VARCHAR(20), -- 'currency_service_spot' | 'manual_override'
-- Mechanism
mechanism VARCHAR(30) NOT NULL,
-- 'destination_charge' | 'separate_transfer' | 'razorpay_route' | 'manual'
provider_transfer_id VARCHAR(255),
-- State
status VARCHAR(30) NOT NULL DEFAULT 'pending',
-- 'pending' | 'in_transit' | 'paid' | 'failed' | 'reversed'
scheduled_for TIMESTAMPTZ, -- for nightly / weekly batched settlements
settled_at TIMESTAMPTZ,
failure_reason TEXT,
-- Audit
actor_id VARCHAR(255) NOT NULL, -- never null; system actions use 'system:scheduler:settlement_sweep'
metadata_json JSONB DEFAULT '{}'::jsonb,
raw_provider_payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_csett_tenant_status ON commerce_settlements(tenant_id, status);
CREATE INDEX ix_csett_vendor ON commerce_settlements(vendor_provider_id);
CREATE INDEX ix_csett_payment_intent ON commerce_settlements(payment_intent_id);
CREATE INDEX ix_csett_scheduled ON commerce_settlements(scheduled_for);
For batched / nightly settlements, the payment_intent_id is NULL and the join table records the bundle (added to PR-1b scope):
CREATE TABLE commerce_settlement_intents (
settlement_id UUID NOT NULL REFERENCES commerce_settlements(id) ON DELETE CASCADE,
payment_intent_id UUID NOT NULL REFERENCES commerce_payment_intents(id) ON DELETE RESTRICT,
amount_minor_units BIGINT NOT NULL,
PRIMARY KEY (settlement_id, payment_intent_id)
);
Admin queries in §11.4 join through commerce_settlement_intents when payment_intent_id IS NULL on the settlement row.
4.5 commerce_commission_schedules — per-vendor fee config¶
CREATE TABLE commerce_commission_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
-- Scope
vendor_provider_id UUID REFERENCES providers(id), -- NULL = platform-default schedule
source_domain VARCHAR(40), -- NULL = any; or 'transport_booking' / 'quote_acceptance'
-- Schedule shape
shape VARCHAR(20) NOT NULL,
-- 'flat' | 'percentage' | 'tiered' | 'hybrid'
flat_fee_minor_units BIGINT, -- shape=flat or hybrid
percentage_bps INTEGER, -- shape=percentage or hybrid; basis points 0..10000
-- TODO(PR-1a): add CHECK (percentage_bps IS NULL OR percentage_bps <= 5000); >50% requires ADR + third approver
tiered_config_json JSONB, -- shape=tiered; e.g. [{"up_to": 100000, "bps": 1500}, {"up_to": 500000, "bps": 1000}, {"bps": 800}]
currency VARCHAR(3), -- NULL = applies to all currencies; else scoped
-- Versioning
effective_from TIMESTAMPTZ NOT NULL,
effective_to TIMESTAMPTZ, -- NULL = open-ended
version INTEGER NOT NULL DEFAULT 1,
-- Admin metadata
created_by_actor_id VARCHAR(255) NOT NULL,
approved_by_actor_id VARCHAR(255),
approval_notes TEXT,
metadata_json JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(shape = 'flat' AND flat_fee_minor_units IS NOT NULL)
OR (shape = 'percentage' AND percentage_bps IS NOT NULL)
OR (shape = 'tiered' AND tiered_config_json IS NOT NULL)
OR (shape = 'hybrid' AND flat_fee_minor_units IS NOT NULL AND percentage_bps IS NOT NULL)
)
);
CREATE INDEX ix_ccs_tenant_vendor ON commerce_commission_schedules(tenant_id, vendor_provider_id);
CREATE INDEX ix_ccs_effective ON commerce_commission_schedules(effective_from, effective_to);
-- partial unique: one currently-effective schedule per (vendor, source_domain, currency)
-- Postgres 15+ NULLS NOT DISTINCT treats NULL == NULL for uniqueness — preferred.
-- TODO(PR-1a): confirm Neon Postgres minor version supports NULLS NOT DISTINCT (PG15+); fall back to two-partial-index pattern documented in §4.5 if not
-- Curaway runs Postgres 16 (per `.github/workflows/ci.yml` postgres:16-alpine), so this form is supported.
CREATE UNIQUE INDEX ux_ccs_current ON commerce_commission_schedules
(vendor_provider_id, source_domain, currency)
NULLS NOT DISTINCT
WHERE effective_to IS NULL;
-- If targeting Postgres < 15 (historical note — not Curaway's case), fall back to two partial indexes:
-- CREATE UNIQUE INDEX ux_ccs_current_with_vendor ON commerce_commission_schedules
-- (vendor_provider_id, COALESCE(source_domain, ''), COALESCE(currency, ''))
-- WHERE vendor_provider_id IS NOT NULL AND effective_to IS NULL;
-- CREATE UNIQUE INDEX ux_ccs_current_platform_default ON commerce_commission_schedules
-- (COALESCE(source_domain, ''), COALESCE(currency, ''))
-- WHERE vendor_provider_id IS NULL AND effective_to IS NULL;
Selection rule (resolved in CommissionService.resolve_for(...)):
1. Match on vendor_provider_id + source_domain + currency — most specific wins.
2. Fall back to vendor_provider_id + source_domain + currency NULL.
3. Fall back to vendor_provider_id + source_domain NULL + currency NULL.
4. Fall back to platform-default (vendor_provider_id NULL).
5. If no match: log WARNING, emit Telegram alert, fail-closed (do NOT proceed without an explicit schedule).
4.6 commerce_refunds¶
CREATE TABLE commerce_refunds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
payment_intent_id UUID NOT NULL REFERENCES commerce_payment_intents(id) ON DELETE RESTRICT,
-- Money
amount_minor_units BIGINT NOT NULL CHECK (amount_minor_units > 0),
currency VARCHAR(3) NOT NULL,
is_partial BOOLEAN NOT NULL,
-- Reason taxonomy
reason VARCHAR(40) NOT NULL,
-- 'patient_request' | 'duplicate_charge' | 'service_not_rendered' | 'quality_issue' | 'fraud' | 'chargeback_concession' | 'admin_correction' | 'other'
-- TODO(PR-1b): add CHECK (reason IN (...closed taxonomy from §4.6 prose))
reason_note TEXT, -- free-text required for 'other'
-- Provider
provider VARCHAR(20) NOT NULL,
provider_refund_id VARCHAR(255),
internal_idempotency_key VARCHAR(64) NOT NULL UNIQUE,
-- State
status VARCHAR(30) NOT NULL DEFAULT 'pending',
-- 'pending' | 'succeeded' | 'failed' | 'cancelled'
failure_reason TEXT,
-- Audit
actor_id VARCHAR(255) NOT NULL, -- who clicked refund
approved_by_actor_id VARCHAR(255), -- second pair of eyes for refunds > $5000 USD eq
metadata_json JSONB DEFAULT '{}'::jsonb,
raw_provider_payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_cref_tenant_status ON commerce_refunds(tenant_id, status);
CREATE INDEX ix_cref_payment_intent ON commerce_refunds(payment_intent_id);
CREATE INDEX ix_cref_reason ON commerce_refunds(reason);
4.7 commerce_receipts¶
CREATE TABLE commerce_receipts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
payment_intent_id UUID REFERENCES commerce_payment_intents(id) ON DELETE RESTRICT,
refund_id UUID REFERENCES commerce_refunds(id) ON DELETE RESTRICT,
-- Document
receipt_number VARCHAR(40) NOT NULL UNIQUE, -- e.g. CRW-RCPT-2026-00001
template_version VARCHAR(20) NOT NULL,
pdf_r2_key VARCHAR(512), -- presigned URL is generated on-demand
pdf_sha256 VARCHAR(64),
-- Send log
email_sent_to VARCHAR(255), -- field-level encrypted at rest via `app/services/encryption.encrypt_field` (mirrors `patients.email_encrypted`; verify match in PR-1b — do NOT invent a new helper). Note: `patients.email` is stored as `email_encrypted` today (Fernet/AES-128-CBC via `app/services/encryption`); no plaintext column exists.
email_sent_at TIMESTAMPTZ,
email_send_status VARCHAR(20), -- 'pending' | 'sent' | 'bounced' | 'failed'
email_send_error TEXT,
-- Audit
metadata_json JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
(payment_intent_id IS NOT NULL AND refund_id IS NULL)
OR (payment_intent_id IS NULL AND refund_id IS NOT NULL)
)
);
CREATE INDEX ix_crcpt_tenant_created ON commerce_receipts(tenant_id, created_at DESC);
CREATE INDEX ix_crcpt_payment_intent ON commerce_receipts(payment_intent_id);
pdf_r2_key triggers R2 cascade delete on GDPR erasure. email_sent_to is encrypted with a per-row key derived from the patient's GDPR key; erasure crypto-shreds the per-row key (the row remains but the email becomes unreadable). Regenerated receipts post-erasure render [anonymised] in the email field.
Receipt PDF template fields (config/receipt_templates/v1/SCHEMA.md):
- Permitted: amount, currency, transaction_date, vendor_name, vendor_address (commercial), line_items[{commercial_label, amount}], tax_amount, total, payment_method_summary (e.g. 'Visa ending in 4242' — last4 only), platform_branding, receipt_number, support_email.
- Forbidden in receipt template: patient name (other than greeting line if patient logged in — gated by a separate flag), procedure_name, procedure_code (ICD-10/CPT/SNOMED), clinical narratives, FHIR-shaped JSON.
- commercial_label examples: "Consultation booking — General medicine", "Specialist procedure consultation". NEVER "Knee replacement surgery" or similar clinically-detailed strings.
PR-7 ships with this allow-list pinned; template changes require compliance review.
R2 receipt PDFs are encrypted at rest via Cloudflare R2's server-side encryption (SSE-S3 equivalent) — default for all R2 buckets, BUT the spec mandates explicit R2_RECEIPTS_BUCKET separate from the existing R2 bucket(s) with confirmed SSE enabled. Bucket policy denies un-encrypted uploads.
Presigned URL TTL: all receipt download URLs have expires_in=900 (15 minutes), single-use where R2 supports it. URL generation is gated through ReceiptsService.generate_presigned_url(receipt_id, actor) which writes an audit row to commerce_receipt_access_log (new table — added to PR-1b scope):
CREATE TABLE commerce_receipt_access_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id),
receipt_id UUID NOT NULL REFERENCES commerce_receipts(id),
actor_id VARCHAR(255) NOT NULL,
presigned_url_sha256 VARCHAR(64) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
4.8 RLS + repositories¶
All seven tables get the tenant_isolation policy (mirrors recovery_provider_profiles template). Repositories live at app/repositories/commerce_*.py and extend BaseRepository (tenant-scoped). The single exception is commerce_ledger_entries repository which adds no update / delete methods — only insert, find_by_id, search, aggregate. Append-only enforced at both the DB grant level and the repository interface level.
5. Provider abstraction¶
5.1 Registry pattern¶
Mirror app/services/matching/registry.py — file-level registry, decorator-based registration, lazy SDK imports.
# app/services/commerce/provider_registry.py
_REGISTRY: dict[ProviderName, Callable[[], PaymentProvider]] = {}
def register_provider(name: ProviderName) -> Callable[[type[PaymentProvider]], type[PaymentProvider]]:
def deco(cls: type[PaymentProvider]) -> type[PaymentProvider]:
_REGISTRY[name] = cls
return cls
return deco
def get_provider(name: ProviderName) -> PaymentProvider:
if name not in _REGISTRY:
# Lazy import — only load the SDK that's actually needed
if name == "stripe":
from app.services.commerce.providers.stripe import StripeCommerceProvider # noqa
elif name == "razorpay":
from app.services.commerce.providers.razorpay import RazorpayCommerceProvider # noqa
else:
raise UnknownProviderError(name)
return _REGISTRY[name]()
5.2 Adapter responsibilities¶
Each provider adapter is the only place that knows provider-specific units, currency rules, and SDK shapes. The rest of commerce works with amount_minor_units: int and currency: str (ISO 4217).
- Stripe (
app/services/commerce/providers/stripe.py): wrapsstripe.PaymentIntent,stripe.Subscription,stripe.Refund,stripe.Transfer. Supports destination charges (transfer_data={"destination": acct_xxx, "amount": platform_fee}). Webhook signature viastripe.Webhook.construct_event. Currency unit map: most currencies are cents (*_per_major=100), zero-decimal exceptions (JPY, KRW) handled. - Razorpay (
app/services/commerce/providers/razorpay.py): wraps RazorpayOrder,Subscription,Refund,Transfer. Route splits viatransfersarray on Order. Webhook signature viarazorpay.utility.verify_webhook_signature. Currency unit map: INR is paise (100 per major). - Future providers (Tap, Pagar.me, Adyen): add by implementing the Protocol +
@register_provider("name"). No commerce-core changes.
5.3 Provider selection¶
The existing app/services/payments/routing.py:pick_payment_provider continues to be the resolution function. PaymentIntentService.create_intent(...) calls it with currency, country_code, tenant_settings_provider, data_residency_region and gets back 'stripe' | 'razorpay'. No new routing logic.
6. Webhook routing¶
6.1 Unified endpoint¶
Path param: stripe | razorpay. Body: raw bytes (no FastAPI Pydantic parsing — we need the exact bytes for signature verification).
Handler flow:
- Look up provider adapter via
get_provider(name). - Call
provider.webhook_verify(payload, signature_header, tolerance_seconds=300). For Stripe, this passestolerance=300tostripe.Webhook.construct_event(rejects events whose embedded timestamp is older than 5 minutes). For Razorpay, the adapter checks(provider_event_id, occurred_at)againstwebhook_events: reject ifnow() - occurred_at > 7 days(Razorpay event retention window). RaiseWebhookSignatureExpired(HTTP 400) on stale events even if the cryptographic signature is valid. RaiseInvalidWebhookSignature(HTTP 400) on signature mismatch. - Call
provider.webhook_parse(payload)— returnsCommerceWebhookEvent(canonical type:event_id,event_typefrom a closed enum,payment_intent_provider_id, raw payload). - Insert / upsert into
webhook_eventstable (existing table — global idempotency log; reuse the existing(provider_name, provider_event_id)UNIQUE constraint). - If duplicate → return 200 immediately (already processed).
- Route by canonical
event_typeto a handler incommerce/webhook_handlers.py. Handlers write ledger rows, update intent state, emit downstream events (Q3 — sync to start, QStash later). - Mark
webhook_events.processed = truein the same transaction.
HTTP status contract for POST /api/v1/commerce/webhooks/{provider}:
| Scenario | Response | Provider behaviour |
|---|---|---|
| Signature valid + event new + processed cleanly | 200 OK |
Stripe / Razorpay mark delivered |
| Signature invalid | 400 Bad Request |
Stripe stops retry; alert (signature mismatch is forge attempt) |
| Signature timestamp-expired | 400 Bad Request |
Same |
Event already in webhook_events (duplicate) |
200 OK |
Idempotent |
| Signature valid + transient internal failure (DB down, Redis down, etc.) | 503 Service Unavailable |
Stripe / Razorpay retry per their exponential backoff |
| Signature valid + permanent processing error (e.g. unknown event_type AFTER allow-list check) | 200 OK + log warning |
Don't make Stripe retry unrecoverable events |
| Event 5xx-retried > 5 times | route to DLQ table commerce_webhook_dlq |
Manual review via §11.8 |
commerce_webhook_dlq is added to PR-1b scope (small table: event_id, payload_sha256, last_error, retry_count, first_seen, last_seen).
6.1.1 Tenant scoping for webhook log viewer¶
The existing webhook_events table is global (no tenant_id). For the admin viewer (§11.8), commerce writes a derived row to a new tenant-scoped table commerce_webhook_log whenever processing succeeds:
CREATE TABLE commerce_webhook_log (
id UUID PRIMARY KEY,
tenant_id VARCHAR(36) NOT NULL REFERENCES tenants(id),
webhook_event_id UUID NOT NULL REFERENCES webhook_events(id),
provider VARCHAR(20) NOT NULL,
canonical_event_type VARCHAR(60) NOT NULL,
payment_intent_id UUID REFERENCES commerce_payment_intents(id),
refund_id UUID REFERENCES commerce_refunds(id),
settlement_id UUID REFERENCES commerce_settlements(id),
subscription_id UUID REFERENCES commerce_subscriptions(id),
processing_status VARCHAR(20) NOT NULL,
ledger_pair_ids UUID[],
payload_sha256 VARCHAR(64) NOT NULL,
-- sha256 of raw_payload bytes at receipt time; replay handler re-verifies before processing
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Tenant is derived from the matched commerce row at processing time. Super-admin operators see all rows in §11.8; tenant admins see only their tenant. Add to PR-1a schema scope.
Retention policy: webhook_events + commerce_webhook_log rows older than 90 days are archived nightly to R2 (gzip-compressed JSON-lines, partitioned by date) via a scheduled QStash job (commerce_webhook_archive_sweep). Hot rows in Postgres remain for replay (§11.8) within the 90-day window; older replay requires admin to fetch from R2 archive (separate workflow). DLQ table (commerce_webhook_dlq) has no TTL — manual review required to discharge.
webhook_events.raw_payload is INSERT-only for curaway_app — no UPDATE grant on this column. Defense in depth alongside commerce_webhook_log.payload_sha256: even if a privileged session attempts to mutate a stored payload pre-replay, the grant denies the UPDATE, and the sha256 mismatch detection in §11.8 would catch any successful tamper.
6.1.2 Provider payload redaction¶
Before inserting raw_provider_payload into ANY commerce table, payloads pass through app/services/commerce/redactor.py:redact_provider_payload(payload, provider) which strips:
- billing_details.name, billing_details.email, billing_details.phone, billing_details.address.* (Stripe)
- customer.email, customer.contact, customer.name (Razorpay)
- shipping.name, shipping.address.*
- card.last4, card.brand, card.exp_month, card.exp_year, payment_method_details.card.* (PCI scope)
- metadata.patient_email, metadata.patient_name (defensive — should never be set, but redact if present)
Retained: id, object, amount, currency, status, created, all platform-controlled metadata keys, all provider tokens (pi_xxx, pm_xxx, ch_xxx).
Redactor is a pure function with snapshot tests in PR-1a (or PR-1b alongside the first JSONB write).
First-call invariant: redact_provider_payload(payload, provider) is the FIRST function called on every inbound provider payload, BEFORE any logger.* call, sentry_sdk.capture_exception, Langfuse span attribute, OR DB write. Implementer-side enforcement: every commerce service entry point that receives a provider payload starts with payload = redact_provider_payload(payload, provider); the un-redacted variable goes out of scope immediately. Any exception handler that receives the original payload must call redact_provider_payload before logging the exception. PR-1a unit test: stub logger, raise from inside the parse path, assert no un-redacted PII in any log call.
6.2 Canonical event types¶
| Canonical | Stripe source | Razorpay source |
|---|---|---|
commerce.payment_intent.authorized |
payment_intent.amount_capturable_updated |
order.paid (when capture_method=manual not supported, treat order.paid as auth+capture combined) |
commerce.payment_intent.captured |
payment_intent.succeeded |
payment.captured |
commerce.payment_intent.cancelled |
payment_intent.canceled |
order.cancelled (rare) |
commerce.payment_intent.failed |
payment_intent.payment_failed |
payment.failed |
commerce.refund.succeeded |
charge.refunded (with refund object) |
refund.processed |
commerce.refund.failed |
charge.refund.updated (status=failed) |
refund.failed |
commerce.subscription.created |
customer.subscription.created |
subscription.activated |
commerce.subscription.updated |
customer.subscription.updated |
subscription.updated |
commerce.subscription.deleted |
customer.subscription.deleted |
subscription.cancelled |
commerce.subscription.invoice_paid |
invoice.paid |
subscription.charged |
commerce.subscription.invoice_failed |
invoice.payment_failed |
subscription.pending |
commerce.dispute.created |
charge.dispute.created |
(Razorpay has no dispute concept; map from payment.failed w/ reason='chargeback' once supported) |
commerce.transfer.paid |
transfer.paid |
transfer.processed |
commerce.transfer.failed |
transfer.failed |
transfer.failed |
Unknown event types are accepted (200) and logged. No silent failures, no rejections of unknown events (Stripe especially will add new event types over time).
6.3 Coexistence with existing webhook routes¶
Per Q7 default — keep existing routes:
POST /api/v1/stripe/webhooks(existing) — keeps routing throughapp/services/payments/. Stays during migration.POST /api/v1/razorpay/webhooks(existing PR-A scope; Q1 paused but route stub may exist) — same.POST /api/v1/commerce/webhooks/{provider}(new) — only routes events forpayment_intent_ids that exist incommerce_payment_intents. If a webhook references a provider intent that's not in commerce, it gets404from commerce and the existing route handles it.
The "which intents live in commerce" gate is the strangler boundary. As callers migrate to commerce, more intents move under commerce's roof. After the migration completes, the old routes get redirect responses (308) for ~30 days, then are deleted.
Migration-window race resolution: during the window when both /api/v1/stripe/webhooks (legacy) and /api/v1/commerce/webhooks/stripe (commerce) are live, BOTH routes attempt to insert into webhook_events with the same (provider_name, provider_event_id). Resolve race via Postgres advisory lock keyed on (provider_event_id, provider_name). Both legacy and commerce routes acquire pg_try_advisory_xact_lock(hashtext(provider + ':' + event_id)) before insert into webhook_events. First to acquire wins; other route returns 200 immediately + log line "skipped — sibling route processed". Advisory locks are session-tx scoped; auto-released on commit/abort. No TOCTOU window. Authoritative routing:
- If a payment_intent exists in commerce_payment_intents matching the event → commerce route is authoritative. Legacy route returns 200 + skip-processing.
- If no commerce row matches → legacy route is authoritative.
- Both routes check the other system's existence before processing. Implement via a shared _is_commerce_intent(event) helper read-only function in app/services/commerce/__init__.py.
6.4 Rate limits¶
All commerce HTTP endpoints enforce per-actor and per-IP token-bucket rate limits via the existing app/middleware/rate_limit.py (or equivalent — verify the actual module name in PR-5a). Defaults:
| Endpoint | Per-actor cap | Per-IP cap | Burst |
|---|---|---|---|
POST /api/v1/commerce/refunds |
30 / min | 60 / min | 5 |
POST /api/v1/commerce/webhooks/{provider} |
N/A | 600 / min | 100 |
GET /admin/commerce/* (read-only) |
600 / min | 1200 / min | 100 |
POST /admin/commerce/settlements/{id}/override |
10 / min | 20 / min | 2 |
POST /admin/commerce/commission-schedules |
20 / min | 40 / min | 5 |
Webhook endpoint allow-lists Stripe + Razorpay published IP ranges with elevated per-IP cap (no spec-time IP pinning — config-driven via commerce_webhook_ip_allowlist Flagsmith flag). 429 responses include Retry-After header.
6.5 Health endpoint¶
GET /health/commerce returns aggregate commerce health (200 if all green; 503 if any degraded):
{
"status": "ok" | "degraded" | "down",
"checks": {
"stripe_api": {"status": "ok", "latency_ms": 42, "last_checked": "..."},
"razorpay_api": {"status": "ok", "...": "..."},
"r2_receipts": {"status": "ok", "...": "..."},
"currency_service": {"status": "ok", "...": "..."},
"ledger_balance_invariant": {"status": "ok", "imbalanced_pairs_24h": 0},
"webhook_dlq_size": {"status": "ok", "size": 0}
},
"version": "commerce@<git-sha>"
}
Caches results for 30s (Redis). Adds commerce to the existing app/services/health/landscape.py registry — per DOD line ~236, new external dependencies (Stripe, Razorpay, R2 receipts bucket, currency_service) register on /landscape for the health-page UI. Added to PR-5a scope.
6.6 Webhook event payload shape¶
@dataclass(frozen=True)
class CommerceWebhookEvent:
provider: ProviderName
event_id: str # provider event id (for idempotency)
event_type: str # canonical (see §6.2)
payment_intent_provider_id: str | None
subscription_provider_id: str | None
refund_provider_id: str | None
transfer_provider_id: str | None
occurred_at: datetime
raw_payload: dict
7. Multi-currency handling¶
7.1 Storage convention¶
Per CLAUDE.md "Architecture Summary → Data Patterns": all money in smallest unit + ISO 4217. Commerce extends this to non-USD: USD → cents (×100), INR → paise (×100), EUR → cents (×100), GBP → pence (×100), AED → fils (×100). JPY / KRW (zero-decimal currencies) → no multiplier; provider adapters handle.
The provider adapter has the canonical map (PaymentProvider.minor_unit_per_major). Commerce services use the map only when displaying — internally everything is amount_minor_units: int.
7.2 Cross-currency settlement¶
When a patient pays USD and the vendor settles INR:
PaymentIntentService.create_intent(...)recordscurrency='USD',vendor_currency='INR'.- At capture,
SettlementService.create_settlement(...)callscurrency_service.convert(usd_minor_units, 'USD', 'INR', as_of=now())and gets back(inr_minor_units, fx_rate). - Settlement row stores
fx_rate+fx_source='currency_service_spot'. - Ledger gets three pairs (per §4.2):
- Patient charge:
+USD → platform_pass_through USD - Settlement:
platform_pass_through USD → vendor_payable INR - FX spread: spread amount in INR captured (the difference vs the rate displayed to the patient at quote time — see Q6).
currency_service outage policy: if currency_service.convert(...) raises or times out (per §3.4 budget), SettlementService accepts a rate AT MOST 24 hours stale from a Redis-cached "last good rate" (commerce:fx:{from}:{to} key, TTL 25h, written on every successful conversion). If even the cached rate is > 24h stale OR Redis is unavailable, settlement fails-closed with failure_reason='fx_unavailable' + Telegram alert + queued for admin retry. Captures (patient charge) proceed regardless — money is held at platform until FX is available.
7.3 Display currency vs settlement currency (Q6)¶
Per Q6 default (hybrid):
- Quote acceptance displays the locked rate (USD price computed from INR procedure cost at quote-creation time). Patient pays the locked USD amount.
- Settlement uses the rate-of-day at capture. The delta — positive or negative — is captured as an fx_spread ledger entry.
- The locked rate stays the patient-facing truth (no "you'll be charged more if INR moves" surprise). The platform absorbs / earns the spread.
Open question: how long is the locked rate valid? Default: until the existing provider_quotes.expires_at (30 days per ADR-0018 §additional decisions). Beyond that the quote is invalid anyway.
7.4 Per-currency invariants¶
- A ledger pair never crosses currencies. Cross-currency events emit two or three single-currency pairs (one per leg + one FX spread pair), all sharing a
metadata_json.fx_link_idUUID. Admin views (§11.1) can group byfx_link_idto display the full multi-leg flow. - Currency conversion always through
currency_service. No commerce-side rate caching.
8. Commission model¶
8.1 Schedule shapes¶
Per §4.5, the shape column drives computation:
flat: every transaction subtractsflat_fee_minor_unitsregardless of amount.percentage: subtractamount_minor_units * percentage_bps / 10000. Basis points so we have 0.01% granularity without floats.tiered:tiered_config_jsonis a list of{up_to_minor_units, bps}tiers. The transaction amount falls into one tier and uses itsbps. Optional final tier with noup_tois the "above all tiers" rate.hybrid:flat_fee_minor_units+ percentage. Both apply.
8.2 Resolution algorithm¶
CommissionService.resolve_for(tenant_id, vendor_provider_id, source_domain, currency, amount_minor_units, intent_override_minor_units) → (platform_fee_minor_units, vendor_net_minor_units, schedule_id):
- If
intent_override_minor_unitsis not None → return(intent_override_minor_units, amount_minor_units - intent_override_minor_units, None). Per-transaction override bypasses schedule. Used for admin "manual fee" edge cases. - Else query for the active schedule per §4.5 selection rule.
-
Compute fee from shape; clamp
fee <= amount_minor_units(never negative net to vendor). -
Return.
8.3 Versioning¶
Schedules are immutable once created. Edits create new rows; the previous row's effective_to gets set to now() and the new row's effective_from = now(). Same audit pattern as facilitator-commission (commission-ledger-feature.md §4.2 — frozen-at-accrual). A transaction processed at time T uses the schedule whose effective_from <= T < effective_to.
Migration / backfill: on PR-5a (schedule seeding), seed one platform-default schedule with shape='percentage', percentage_bps=1500 (15%) + effective_from=epoch, effective_to=NULL. Real per-vendor schedules added via admin UI (§11.5).
9. Settlement flow¶
9.1 Destination charges (preferred)¶
Stripe Connect supports transfer_data={"destination": vendor_account_id, "amount": vendor_net_minor_units} on PaymentIntent creation. The capture event splits the money in one atomic operation. Three ledger pairs in one event: patient charge, commission (platform retains), vendor payable (vendor receives). One row in commerce_settlements with mechanism='destination_charge'.
Pros: atomic, single fee from Stripe, no reconciliation drift. Cons: only works if vendor has a Stripe Connect account in compatible region.
Before creating a destination charge, SettlementService.create_settlement(...) calls provider.fetch_account_capabilities(vendor_account_id) and verifies charges_enabled=true AND payouts_enabled=true. If either is false, fail-closed: write commerce_settlements row with status='failed', failure_reason='vendor_kyc_incomplete', ledger reversal, Telegram alert. Vendor account_id-level capability check happens at intent creation, not capture, to give the patient a clear error before they pay.
Second capability check at settlement time (TOCTOU defense). A SECOND capability check runs inside SettlementService.create_settlement(...) immediately before the destination charge or transfer call. Between intent creation and settlement, vendor accounts can be frozen, lose charges_enabled, or land on a sanctions list. If the settlement-time check fails:
- Write
commerce_settlements.status='failed', failure_reason='vendor_kyc_changed_post_intent' - Emit ledger reversal pair for any optimistic vendor_payable entries
- Telegram alert with
vendor_provider_id+vendor_account_id(no PII) - Patient-facing: existing capture proceeds (money already at platform); refund queued via admin §11.4 with
auto_refund_eligible=trueflag
9.2 Razorpay Route¶
Razorpay's equivalent: Orders with a transfers array. Same atomicity, same ledger shape. mechanism='razorpay_route'.
9.3 Separate transfers (fallback)¶
For vendors without a Connect / Route account (or in regions Stripe/Razorpay doesn't reach):
- Capture goes to the platform account.
- SettlementService.create_settlement(...) schedules a separate transfer via provider.transfer_to_vendor(...).
- mechanism='separate_transfer'. Two webhook events (capture, then transfer.paid) write to ledger separately.
Per Q5 default — destination charges as default; separate transfers only when destination not available. Batch nightly settlements (mechanism='manual' / 'batched') are reserved for the case where vendor cannot receive money via provider APIs at all (e.g. small regional providers paid via local wire). Out of scope for MVP.
9.4 Failure handling¶
commerce_settlements.status = 'failed'triggers a Telegram alert + admin queue entry (§11.4).- Ledger gets a
reversalentry pair reversing the optimistic vendor_payable credit. - Vendor account_id-level failures (account closed, account frozen) flagged in
commerce_settlements.metadata_json.vendor_account_status; matcher (Phase 7 transport) reads this flag and de-prioritises the vendor. - Retry policy: failed transfers auto-retry on exponential backoff over a 24-hour window: 1min, 5min, 30min, 2h, 8h, 24h (6 attempts). After 24h or 6 attempts (whichever first), settlement remains
failed+ Telegram alert + visible in admin §11.4 DLQ-like view. Admin can manually retry up to 5 additional times via §11.4; further retries require ADR. Backoff jitter ±20%.
10. Refund flow¶
10.1 Boundary¶
The commerce module does the money refund. It does NOT mutate downstream domain state (quote status, consultation status, transport booking status). Instead, on successful refund it emits commerce.refund.completed. Downstream domains listen and update their own state.
This is the key architectural rule: commerce never imports from quote / consultation / transport domains. The communication is one-way (events out) per CLAUDE.md ground rule #7.
10.2 API¶
POST /api/v1/commerce/refunds
body: {
"payment_intent_id": "...",
"amount_minor_units": null, // null = full refund
"reason": "patient_request",
"reason_note": "Coordinator note",
"caller_idempotency_key": "uuid", // required UUID v4; admin UI MUST supply for retry safety
"metadata": {...}
}
<!-- TODO(PR-5b): caller_idempotency_key validation: regex ^[A-Za-z0-9_-]{8,64}$ at router, reject PII-shaped inputs -->
-> RefundResponse { "refund_id": ..., "status": "pending", ... }
Required permission: commerce:refund:create (admin / coordinator with specific grant). Refunds over $5000 USD-equivalent require a second approver (approved_by_actor_id) — enforced at the service layer.
Patient-facing error strings (e.g. when a refund fails through the patient view, or a payment retry surfaces an error) route through config/voice_rules.yaml and the LLM (per CLAUDE.md §Brand Voice). Hard-coded strings limited to internal admin errors (per CLAUDE.md "NO hardcoded user-facing responses" — exceptions: server errors, rich_content cards, test fixtures).
10.3 Provider call¶
RefundService.refund(payment_intent_id, amount, reason, actor):
- Resolve
source_domain + source_id→ underlying case_id via the appropriate domain service: source_domain='quote_acceptance'→quote_service.get_case_id_for_quote(source_id)source_domain='consultation_charge'→consultation_service.get_case_id_for_charge(source_id)source_domain='transport_booking'→transport_service.get_case_id_for_booking(source_id)source_domain='admin_adhoc'→ no case_id; route requirescommerce:refund:create+admin:forcepermissions.caller_idempotency_keybody must includecase_access_exempt_reason∈ {compliance_review,regulator_request,duplicate_charge_outside_case,merchant_dispute_settlement}. Free-text reasons rejected at the router with HTTP 422. Every exempt-path refund triggers a Telegram alert (severityinfo) to the compliance channel withactor_id,payment_intent_id, and exempt_reason — surfaces every exempt use immediately for human review.
Then call the standard require_case_access(case_id, actor) from app/middleware/case_access.py. Admin-only refunds (no case) take the CASE_ACCESS_EXEMPT path with a justification recorded in metadata_json.case_access_exempt_reason (closed taxonomy above). The case-access call must use the same exempt-scanner-friendly pattern as provider_quotes.py:accept_quote.
- Validate intent exists, status ∈ {
captured,settled,partially_refunded}. - Compute
remaining_refundable = intent.amount_minor_units - sum(successful_refunds.amount). Refund amount must be> 0 AND <= remaining. - Generate idempotency key:
hash(payment_intent_id + ':refund:' + caller_idempotency_key). The caller MUST passcaller_idempotency_keyas a UUID v4 in the request body (or HTTPX-Idempotency-Keyheader per Curaway API convention — CLAUDE.md "API Conventions"). The same key + same intent always resolves to the same refund row; different keys produce different refunds even within the same second. If no caller key is provided, the service generates one — but admin UI MUST supply one to support retries. - Two-transaction pattern (avoids holding DB tx across HTTP):
- Tx 1: insert
commerce_refundsrow withstatus='pending', commit immediately. The pending row + idempotency key protects against double-charge on retry. - Provider call:
provider.refund(provider_intent_id, amount, idempotency_key, reason, metadata)runs OUTSIDE any open DB transaction. - Tx 2: on success → write ledger pair + update
status='succeeded'+ emit event, commit. On failure → updatestatus='failed'+ recordfailure_reason, no ledger write, commit. - Two-eye invariant: the service rejects any submission where
actor_id == approved_by_actor_id. This prevents an admin holding both:createand:approvepermissions from self-approving in one session. Enforce at the service layer; tests in PR-5b + PR-7 cover the violation case. - On success: status='succeeded', write ledger pair (reverses patient charge), emit
commerce.refund.completed. - On failure: status='failed', record
failure_reason, no ledger write.
FX threshold re-evaluation: the $5,000 USD-equivalent threshold is computed from the refund amount + currency at approval-grant time using a fx_rate_snapshot value that the admin form captures. On the provider call (Tx 2), the service re-evaluates the threshold using the SAME fx_rate_snapshot — NOT the current rate. If FX moves between approval-grant and provider-call such that the rate snapshot would no longer apply (snapshot age > 5 minutes), the service rejects with COMMERCE_REFUND_002 (re-approval required). Prevents rate-drift from silently bypassing the two-eye threshold.
Solo-operator approval policy (decided 2026-05-13): dual approval is the hard rule — no bypass marker, no regulatory_flag='solo_operator_approval' escape hatch. Before PR-7 (admin routers) ships, SD must designate a second approver (a coordinator user granted commerce:refund:approve + commerce:commission:approve + commerce:settlement:override codes) so the two-eye flow has two real eyes. If no second approver is designated by PR-7 land time, the admin write endpoints for these three flows ship behind a Flagsmith flag commerce_admin_writes_enabled (default OFF) until the delegate role is filled. Better to delay writes than to weaken the dual-approval rule.
10.4 Event payload¶
@dataclass(frozen=True)
class CommerceRefundCompleted:
refund_id: UUID
payment_intent_id: UUID
source_domain: str # 'quote_acceptance' | 'consultation_charge' | ...
source_id: str # FK target in source_domain
tenant_id: str
amount_minor_units: int
currency: str
is_partial: bool
reason: str
actor_id: str
occurred_at: datetime
Downstream domain handlers register via a domain-event subscription pattern. Per Q3 default the initial implementation is QStash-driven (matches existing pattern; decouples domains). PR-5b ships QStash; PR-1a..PR-4 can use sync in-process for testability if needed (clearly noted in code as scaffold).
10.5 Cross-domain authz boundary¶
Note this is the ONLY place commerce reads from another domain — but it reads only case_id (a scalar), via a service function. No imports of quote/consultation/transport models or state. Compliant with CLAUDE.md ground rule #7.
11. Admin oversight surface¶
Nine screens. All follow Curaway admin-portal design patterns. Backend routers ship in PR-7; frontend ships in PR-8. (If apps/admin-app/src/pages/ doesn't exist at PR-8 time — it doesn't today — the FE PR is gated on procedure-onboarding-admin-ui.md shipping admin-portal scaffolding first. Tracked as a dependency on PR-8.)
2026-05-14 expansion (PR-8 implementation): SD selected option 3, shipping BOTH Webhook log viewer AND Receipts admin surface. This expanded the admin portal from 8 screens (originally spec'd) to 9 screens. Both surfaces are now live in BE app/routers/admin_commerce.py (12 endpoints; PR #888 commit 22cc33e) + webhook-log/DLQ endpoints (PR #890 commit cfa4807); FE apps/admin-app/src/pages/commerce/*.tsx 9 pages (PR #270 commit 5441af6).
All admin routers import the existing decorator from app/middleware/require_permission.py (the standard Curaway pattern). Permission codes per §11.9. No new decorator pattern.
11.1 Ledger view¶
/admin/commerce/ledger — high-volume table.
- Filters: tenant, vendor (provider FK), date range, status, amount range, currency, entry_type, source_domain. Persisted as URL params for shareable links.
- Columns: created_at, entry_pair_id (links to the pair view), account_type, direction, amount, currency, payment_intent_id (links to §11.2), description.
- Group toggle: group by
entry_pair_idto show balanced pairs side-by-side. Useful for "show me both legs of this charge". - Export to CSV: filtered result; ledger entries are append-only so export reflects a consistent snapshot.
- Pagination: cursor-based on
(created_at, id)to handle millions of rows.
11.2 Payment intent view¶
/admin/commerce/payment-intents/{id} — detail page.
- Header: intent id, tenant, source_domain + source_id (link out to the source artifact — quote / consultation / transport_booking), amount + currency, status, provider, provider_intent_id (link to Stripe / Razorpay dashboard).
- Timeline: event list (created, authorized, captured, settled, refunded events).
- Linked ledger entries: filtered ledger view scoped to this intent.
- Linked settlements: show all settlement rows tied to this intent.
- Linked refunds: show all refunds tied to this intent.
- Linked receipts: show all receipt rows (with download links).
- Raw provider payload: collapsible JSON viewer of
raw_provider_payload. - Actions: manual retry (re-attempt capture if
authorizedand capture failed); manual mark-cancelled (super-admin only, big confirm dialog).
11.3 Refund console¶
/admin/commerce/refunds — list + create.
- List: filterable by status, reason, actor, amount range, date.
- Create form:
- Search for a payment intent (by source_domain + source_id, or by patient name with audit trail).
- Show capture amount + already-refunded total.
- Refund amount input (defaults to remaining; can be partial).
- Reason dropdown (taxonomy from §4.6).
- Reason note (required for
other). - Second-approver field (required if amount > $5000 USD eq).
- Confirm button — shows "$X.XX will be returned to the patient. This action is recorded permanently and cannot be undone." dialog.
- Detail view per refund: full audit trail, ledger entries, retry button if
failed.
11.4 Settlement console¶
/admin/commerce/settlements — list + detail.
- List: filterable by vendor, status, scheduled date, currency, mechanism.
- Pending settlements: queue view of
status IN ('pending', 'in_transit'). - Reconciliation report: per-vendor totals — gross, platform fee, net — over a date range, exportable to CSV.
- Detail per settlement: linked payment_intent, linked ledger entries, provider transfer id (link to provider dashboard), failure reason if failed.
- Manual override: super-admin only — mark a settlement as
paidwithout a provider transfer (for cases where settlement happened via wire transfer outside the platform). Writes amechanism='manual'ledger entry; requires reason text + second approver. - Override validation: on override submission,
SettlementService.validate_external_transfer(provider, provider_transfer_id)callsprovider.fetch_transfer(transfer_id)and verifies: (a) transfer exists, (b) destination matches vendor account, (c) amount matchesvendor_net_minor_units± 1 minor unit (rounding tolerance), (d) currency matches. Validation failure rejects override with HTTP 422 + error codeCOMMERCE_005. Formechanism='manual'(truly off-platform wire transfers with no provider record), the admin must additionally upload a wire-confirmation document (R2) + select justification reason.
11.5 Commission editor¶
/admin/commerce/commissions — schedule management.
- List: all schedules, current + historical. Filter by vendor, source_domain, currency.
- Edit: edits create a new version (old row's
effective_toset to now, new row'seffective_from = now). Immutable history. - Create wizard: vendor (optional — null = platform default), source_domain (optional), currency (optional), shape (flat / percentage / tiered / hybrid), values. Real-time preview: "On a $1000 transaction, vendor would get $X, platform retains $Y."
- Approval flow: create requires
commerce:commission:create; activation requirescommerce:commission:approve. Two-eye approval for production schedules. - Two-eye invariant: the service rejects any submission where
actor_id == approved_by_actor_id. This prevents an admin holding both:createand:approvepermissions from self-approving in one session. Enforce at the service layer; tests in PR-5b + PR-7 cover the violation case. - Audit log: who created, who approved, when, with before/after JSON snapshot.
11.6 Dispute view¶
/admin/commerce/disputes — Stripe chargebacks + Razorpay payment.failed-reason-chargeback.
- List: filter by status (
needs_response,responded,won,lost), date, amount. - Detail per dispute: linked payment_intent, dispute reason, due_by (response deadline), evidence-bundle status, admin notes.
- Actions: acknowledge, add notes, link redacted invoice + service-rendered proof only (e.g. consultation timestamp screenshots, redacted contract excerpts). NEVER raw FHIR / clinical narratives / medical records / patient identifiable documents. Stripe is NOT a BAA processor; uploading clinical PHI to dispute evidence pulls PHI into a non-covered processor. The evidence bundle is verified pre-submission by a coordinator with
compliance:dispute_evidence:approvepermission before the platform allows export. Evidence files have an automated PII/PHI scanner pre-upload that blocksICD-,CPT-,LOINC-,SNOMED-, FHIR-shaped JSON, and known clinical-narrative patterns; only files that pass the scan can be linked.
MVP does NOT auto-submit evidence to providers — admin downloads the evidence bundle and uploads via Stripe / Razorpay dashboards. Submission automation deferred.
11.7 Subscription view¶
/admin/commerce/subscriptions — gated behind Q1 activation.
- List: active / paused / cancelled / past-due per tenant.
- Detail: customer reference, plan, amount, interval, current period, trial status, next billing date.
- Actions: cancel (immediate or at period end), pause, resume, change plan (creates a new sub + cancels old). All admin actions create ledger entries.
- Empty state (pre-Q1 activation): "Subscriptions are not yet enabled. See open architectural question Q1 in
docs/specs/commerce-module-feature.md."
11.8 Webhook log viewer + DLQ¶
/admin/commerce/webhooks — debugging aid + dead-letter queue (DLQ) management.
Main tab — Webhook log:
- List: tenant admins read from commerce_webhook_log (tenant-scoped, joined to webhook_events for payload). Super-admin operators have an additional "global view" toggle that reads raw webhook_events directly (no tenant filter). Filter by provider, event_type, processed status, date.
- Detail per webhook:
- Raw payload (collapsible JSON). Stripe-Signature / X-Razorpay-Signature header values are redacted to [redacted_signature] before render; original signature is stored only as sha256(signature) for audit (prevents low-grade secret leak via admin UI).
- Signature verification result.
- Linked payment_intent / refund / settlement / subscription (if matched).
- Linked ledger entries written from this event.
- Processing duration.
- Replay button (super-admin only) — re-runs the canonical handler on the stored payload. Before re-running handler steps 3-7, the replay handler recomputes sha256(stored_raw_payload) and compares against commerce_webhook_log.payload_sha256. Mismatch → abort + Telegram alert (replay_payload_tampered event type). Replay path bypasses signature verification — we trust our own stored payload, and the sha256 invariant proves it has not been tampered with since receipt. The handler runs steps 3-7 of §6.1 (skip step 2, signature verify). Stripe / Razorpay webhook secret rotation does NOT break replay. Replay writes an actor_id=admin:<id> audit row to commerce_webhook_log to distinguish replay-driven processing from original webhook arrival. Idempotency key derived from event_id; if the event already updated state, the replay is a no-op. Used for debugging.
DLQ tab — Dead-letter queue management:
- List of failed webhooks: rows from commerce_webhook_log where status = 'dlq' or processing_error IS NOT NULL. Filter by date, error message, provider. Displays: event_id, provider, event_type, error_summary, queued_at, retry_count.
- Detail per DLQ entry:
- Full error stack trace (collapsible).
- Raw payload (immutable, for reference).
- Retry history (previous attempts + timestamps).
- Replay button (super-admin only) — same flow as main tab, but FE shows a typed-confirmation modal ("Type 'REPLAY' to confirm this DLQ replay"). POST to POST /api/v1/admin/commerce/webhooks/{webhook_id}/replay with idempotency_key header (required; UUID v4). If the webhook was already successfully replayed (matched by webhook_id), the endpoint returns HTTP 409 ALREADY_REPLAYED. Success updates commerce_webhook_log.status = 'replayed' (immutable transition from 'dlq'). All replay logic as per main tab (sha256 check, signature bypass, audit row with actor_id=admin:<id>, idempotent handler path).
11.9 Receipts (admin view)¶
/admin/commerce/receipts — receipt management and audit trail.
Table view:
- List all receipts scoped to tenant (tenant admins) or globally (super-admin). Filterable by receipt_id, linked payment_intent, date range, email_status.
- Columns: receipt_id (UUID, unique receipt identifier), intent_id (links to §11.2), amount + currency, email_sent_to (displayed masked, e.g. s***@example.com), created_at, has_pdf (boolean indicator).
- Pagination: cursor-based on (created_at, id) to handle high-volume receipts.
Detail view per receipt:
- Header: receipt_id, intent_id (linked), amount + currency, email address (masked), sent timestamp, PDF availability.
- PDF download: calls GET /api/v1/admin/commerce/receipts/{receipt_id} (same endpoint as patient-facing PDF retrieval). Returns a presigned URL (R2) with TTL 900 seconds per spec §4.7. Every detail-view access writes an immutable commerce_receipt_access_log row capturing: receipt_id, actor_id (admin user ID), timestamp, action = view_detail, user_agent, IP (masked last octet). This audit trail fulfills spec §11.8.2 "every admin read is logged".
- Resend email: button (tenant admin+) to trigger a fresh email send with a new presigned URL. Updates receipt's email_sent_at timestamp.
Permission codes: commerce:receipt:read (view table + detail), commerce:receipt:resend (resend email).
Cross-reference: Receipts are generated during payment capture (§4.7, step 2b). The presigned URL strategy and 900-second TTL are shared with patient-facing receipt download. Admin access is identical except it bypasses tenant-to-payment matching — admins can read any tenant's receipts when flagged as tenant_id = NULL (super-admin) or tenant_id = admin's_tenant (tenant admin).
Implemented in PR-8: FE apps/admin-app/src/pages/commerce/ReceiptsList.tsx + ReceiptDetail.tsx (PR #270 commit 5441af6); BE endpoints in app/routers/admin_commerce.py + commerce_receipt_access_log reads (PR #888 commit 22cc33e).
11.10 Permission codes¶
| Code | Grants |
|---|---|
commerce:ledger:read |
View §11.1, §11.2 ledger panels |
commerce:intent:read |
View §11.2 |
commerce:intent:retry |
Manual retry button in §11.2 |
commerce:refund:create |
Create refund in §11.3 |
commerce:refund:approve |
Second-approver for refunds > $5000 |
commerce:settlement:read |
View §11.4 |
commerce:settlement:override |
Manual mark-paid in §11.4 |
commerce:commission:read |
View §11.5 |
commerce:commission:create |
Create schedule |
commerce:commission:approve |
Activate schedule |
commerce:dispute:read |
View §11.6 |
commerce:dispute:respond |
Add notes / evidence in §11.6 |
commerce:subscription:read |
View §11.7 |
commerce:subscription:manage |
Cancel / pause / resume |
commerce:webhook:read |
View §11.8 (webhook log viewer) |
commerce:webhook:replay |
Replay button in §11.8 (webhook log + DLQ) |
commerce:receipt:read |
View §11.9 (receipt table + detail) |
commerce:receipt:resend |
Resend email in §11.9 |
compliance:dispute_evidence:approve |
Pre-submission verification of dispute evidence bundle (§11.6) — gates export when PHI/PII scan passes |
commerce:auditor:read |
View §11.1, §11.2, §11.4, §11.6, §11.8, §11.9 (ledger + intents + settlements + disputes + webhook log + receipts). No write codes. Separate role for SOC 2 / ISO 27001 auditors. |
The auditor role does not grant :create, :approve, :override, or :replay codes. Strict separation of duties.
Seeded in PR-7 alembic migration using the existing _GRANTS tuple + _add_perm helper pattern (dc57d34f821b_*.py precedent).
12. Idempotency¶
12.1 Internal key¶
Each commerce_payment_intent gets internal_idempotency_key = HMAC_SHA256(secret=COMMERCE_IDEMPOTENCY_SALT, msg=source_domain + ':' + source_id + ':create') at creation. UNIQUE constraint on this column means duplicate create calls collapse to the same row — callers don't need to coordinate.
COMMERCE_IDEMPOTENCY_SALT is a 32-byte server-side secret stored in Railway env vars (rotatable; new + old salt supported during a 30-day rotation window per provider key rotation pattern below). The HMAC makes the key UNGUESSABLE without the salt; a caller cannot forge a caller_idempotency_key matching an existing internal_idempotency_key to hijack the operation.
Subsequent operations derive their key from the intent via HMAC with the same salt:
- Capture: HMAC_SHA256(salt, internal_idempotency_key + ':capture')
- Cancel: HMAC_SHA256(salt, internal_idempotency_key + ':cancel')
- Refund: HMAC_SHA256(salt, internal_idempotency_key + ':refund:' + refund_id + ':' + amount_minor_units)
- Transfer: HMAC_SHA256(salt, internal_idempotency_key + ':transfer:' + settlement_id)
All deterministic given the same salt. Replay a webhook handler twice → same idempotency key → provider returns same response → ledger writes idempotent at the service layer (insert with ON CONFLICT (entry_pair_id, account_type, direction) DO NOTHING).
12.2 Provider key passing¶
- Stripe:
Idempotency-KeyHTTP header on every PaymentIntent / Refund / Transfer call. - Razorpay:
idempotency_keyparam on supported endpoints; for endpoints that don't support it natively (some Razorpay older endpoints), the adapter ensures one outstanding call per(operation, target_id)via Redis lock with 60-second TTL.
12.3 Webhook idempotency¶
Reuses existing webhook_events table — (provider_name, provider_event_id) UNIQUE. First write wins; duplicates return 200 without processing.
13. PR roadmap¶
Eight PRs in dependency order. Each PR aims for <500 lines / <10 files per .claude/rules/definition-of-done.md "PR scope" rule. Pattern: schema + repos first, services + routers second, providers third, admin UI last, callers migrated separately.
PR-1a — Core schema: payment_intents + ledger + commission_schedules¶
Scope:
- Alembic migration creating commerce_payment_intents, commerce_ledger_entries, commerce_commission_schedules (3 tables from §4.1, §4.2, §4.5).
- Table creation order (explicit):
1. commerce_commission_schedules (no FK out).
2. commerce_payment_intents (FK to commerce_commission_schedules).
3. commerce_ledger_entries (FK to commerce_payment_intents; late-bound refund_id / settlement_id columns created nullable with NO FK at PR-1a — FK constraints added in PR-1b via op.create_foreign_key after the target tables exist).
- SQLAlchemy models in app/models/commerce/*.py for these 3 tables.
- Repositories in app/repositories/commerce_*.py extending BaseRepository for these 3 tables.
- commerce_ledger_entries repository has insert + read methods only; no update / delete.
- RLS policies on these 3 tables (template from recovery_provider_profiles migration).
- Permission codes seeded for commerce:ledger:read, commerce:intent:read, commerce:commission:read (idempotent, per §11.9).
- Unit tests: model instantiation, repository tenant scoping, ledger insert.
Acceptance:
- Migration applies cleanly + downgrade reverses.
- pytest tests/test_commerce_models.py passes.
- tests/test_route_access_scanner.py not impacted (no new routes yet).
- Architecture review: schema shape locked.
Out of scope: any service logic, any router, any provider call.
PR-1b — Settlement + refunds + subscriptions + receipts schema¶
Scope:
- Alembic migration creating commerce_settlements, commerce_refunds, commerce_subscriptions, commerce_receipts (4 tables from §4.3, §4.4, §4.6, §4.7).
- Table creation order (explicit):
4. commerce_refunds (FK to commerce_payment_intents).
5. commerce_settlements (FK to commerce_payment_intents, providers).
6. commerce_subscriptions (FK to tenants).
7. commerce_receipts (FK to commerce_payment_intents, commerce_refunds).
- After table creation: op.create_foreign_key to wire commerce_ledger_entries.refund_id → commerce_refunds(id) and commerce_ledger_entries.settlement_id → commerce_settlements(id) (the nullable columns were created in PR-1a without FK).
- Also creates commerce_settlement_intents join table (per §4.4) and commerce_webhook_log derived table (per §6.1.1).
- SQLAlchemy models in app/models/commerce/*.py for these 4 tables.
- Repositories in app/repositories/commerce_*.py extending BaseRepository for these 4 tables.
- RLS policies on these 4 tables.
- Permission codes seeded for refund/settlement/commission write permissions (commerce:refund:create, commerce:settlement:read, commerce:settlement:override, commerce:commission:create, commerce:commission:approve) per §11.9.
- Unit tests: model instantiation, repository tenant scoping.
Acceptance:
- Migration applies cleanly + downgrade reverses (FKs to PR-1a tables exist before this migration runs).
- pytest tests/test_commerce_models.py extended tests pass.
Depends on: PR-1a (commerce_commission_schedules FK is in PR-1a; commerce_settlements references commerce_payment_intents from PR-1a).
Out of scope: any service logic, any router, any provider call.
PR-2 — LedgerService + CommissionService¶
Scope:
- app/services/commerce/ledger.py — record_charge, record_refund, record_settlement, record_fx_spread, record_reversal, search, aggregate_by_account. Append-only invariant enforced.
- app/services/commerce/commission.py — resolve_for, schedule selection algorithm per §4.5.
- Unit tests: balance invariant (every helper produces pairs that sum to zero per currency), schedule selection precedence, tiered computation, FX-spread shape.
- No router. No external calls.
Acceptance:
- LedgerService rejects any write that would produce an unbalanced pair.
- CommissionService.resolve_for returns exact expected fee for fixture-loaded schedules at every shape.
- 90% line coverage on both services.
Depends on: PR-1b.
PR-3 — PaymentProvider Protocol + Stripe adapter + provider registry¶
Scope:
- app/services/commerce/__init__.py — Protocol definition (per §3.1), PaymentProvider, DTOs (ProviderPaymentIntent, ProviderRefund, ProviderTransfer, ProviderSubscription, CommerceWebhookEvent).
- app/services/commerce/provider_registry.py — register_provider decorator + get_provider.
- app/services/commerce/providers/stripe.py — implements the Protocol. Wraps stripe.PaymentIntent, stripe.Refund, stripe.Transfer. Webhook verify + parse.
- app/services/commerce/providers/__init__.py.
- Unit tests with stripe Python SDK mocked.
Acceptance: - Stripe adapter passes contract tests: every Protocol method returns the expected DTO shape on success and raises one of the closed-set commerce exceptions on failure. - Webhook parsing handles all canonical event types in §6.2 for Stripe.
Depends on: PR-1a (only for DTO field types).
PR-4 — Razorpay adapter¶
Scope:
- app/services/commerce/providers/razorpay.py — same Protocol contract as Stripe.
- Razorpay-specific notes: order vs payment intent naming, Route splits, INR-paise unit handling, webhook signature differences.
- Contract tests vs Razorpay's Protocol contract — same suite as PR-3, parametrized over both providers.
Acceptance: - Razorpay adapter passes the same contract tests as Stripe. - Both providers under one parametrized test matrix.
Depends on: PR-3.
PR-5a — PaymentIntentService + webhook router¶
Scope:
- app/services/commerce/payment_intent.py — create_intent, authorize, capture, cancel, state machine.
- app/services/commerce/webhook_handlers.py — canonical event handlers for payment_intent.* events per §6.2.
- app/routers/commerce_webhooks.py — POST /api/v1/commerce/webhooks/{provider} endpoint, canonical event routing for payment_intent.* events only.
- Integration tests against Stripe test mode + Razorpay test mode (mocked SDK; no real network).
- Feature flag commerce_module_enabled (default off).
Acceptance:
- Full intent lifecycle test: create → authorize → capture → webhook, ledger balanced at each step.
- Cross-currency test: USD intent → INR vendor → settlement with fx_spread ledger row.
Depends on: PR-2, PR-3, PR-4.
PR-5b — RefundService + SettlementService + remaining webhook handlers¶
Scope:
- app/services/commerce/refund.py — refund, emits commerce.refund.completed.
- app/services/commerce/settlement.py — create_settlement (destination charge / separate transfer / Razorpay Route), mark_settled.
- Remaining webhook handlers in app/services/commerce/webhook_handlers.py — refund/settlement webhook handlers per §6.2.
- app/routers/commerce_refunds.py — POST /api/v1/commerce/refunds.
- QStash event emitter wired (Q3 default — events go to QStash; subscribers in callers).
- Integration tests: refund + settlement flows, mocked SDK.
Acceptance:
- Refund lifecycle test: refund → webhook → ledger reversal pair balanced.
- Refund > $5000 USD without approver → 403.
- Settlement with destination charge + separate transfer both tested.
- tests/test_route_access_scanner.py passes — refund endpoint either has require_case_access or is in CASE_ACCESS_EXEMPT with reason.
Depends on: PR-5a.
PR-6 — Migrate first concrete caller (quote acceptance)¶
Scope:
- app/routers/provider_quotes.py:accept_quote — feature-flag-gated: if commerce_quote_acceptance_enabled (default off, per-tenant), call PaymentIntentService.create_intent(...). Else fall through to existing app/services/payments/ flow.
- Strangler boundary at the router layer; existing code untouched.
- Listener: app/services/quote_lifecycle.py registers QStash subscription for commerce.refund.completed events with source_domain='quote_acceptance' — flips quote status on cancellation.
- Integration test in both modes (flag on / off) covers same caller; behaviour identical on success path.
- Documentation: runbook update for the new path.
Acceptance:
- Quote acceptance works in both modes.
- Refund path: refund commerce intent → event fires → quote status updated → patient notified.
- No regression in existing flow.
- Reconciliation test: after enabling commerce_quote_acceptance_enabled=true for a test tenant, verify that legacy app/services/payments/ ledger entries (or their equivalent) + commerce ledger entries produce the same net financial position for a fixture set of quotes. Diff against a pre-migration snapshot. PR is blocked on diff = 0.
Depends on: PR-5b.
PR-7 — ReceiptsService + admin routers¶
Scope:
- app/services/commerce/receipts.py — PDF template (Jinja under config/receipt_templates/v1/{html,pdf}.j2), R2 upload, notify-service send. Versioned templates.
- app/routers/admin_commerce.py — read-side endpoints powering §11.1, §11.2, §11.4, §11.6, §11.7, §11.8.
- app/routers/admin_commerce_writes.py — write-side: §11.3 refund create, §11.5 commission editor, §11.4 settlement override.
- All admin routes use require_permission per §11.9.
- Permission codes from §11.9 seeded.
Acceptance: - Receipt PDF renders for a fixture intent + refund. - Admin endpoints return tenant-scoped data; cross-tenant request returns 404 (not 403). - Two-eye approval enforced on refunds > $5000 + commission schedule activation.
Depends on: PR-5b.
PR-8 — Admin UI (frontend)¶
Scope:
- apps/admin-app/src/pages/commerce/*.tsx — eight pages mapping to §11.1..§11.8.
- Reuses existing admin-app shadcn/ui components (table, dialog, sheet, dropdown, etc.).
- API client: apps/admin-app/src/services/commerceApi.ts (per .claude/rules/definition-of-done.md parallel-safe file pattern — chain-specific API file).
- Empty states for §11.7 (subscriptions disabled per Q1).
Acceptance: - All 8 screens render with fixture data. - WCAG AA contrast verified. - Mobile breakpoints at 768px (admin not patient — desktop primary).
Depends on: PR-7. Gated on admin-portal scaffolding from procedure-onboarding-admin-ui.md if apps/admin-app/ doesn't exist yet — coordinate with that workstream.
PR-9 (optional) — Migrate consultation charges¶
Scope: same pattern as PR-6 for teleconsultation_payments.py. Behind commerce_consultation_charges_enabled.
Acceptance: consultation charge path runs through commerce; existing path still works under the flag-off branch. Reconciliation test: after enabling commerce_consultation_charges_enabled=true for a test tenant, verify legacy + commerce ledger entries produce the same net financial position for a fixture set of consultation charges. Diff against a pre-migration snapshot. PR is blocked on diff = 0.
PR-10 (post-MVP) — Retire app/services/payments/¶
Scope: flip both per-caller feature flags on for all tenants; delete the legacy routes and service; redirect old webhook routes for a deprecation window; remove app/services/payments/.
Acceptance: all callers on commerce; legacy code gone; webhook redirects active.
14. Open architectural questions for SD review¶
Each question states the issue, lists alternatives, and gives the recommended default. SD: red-pen what disagrees.
Q1 — Subscription scope at PR-1a..PR-8¶
Issue: subscription.py shape needs to be decided now (table + Protocol method) but no concrete caller exists today. Implementing without a caller risks building the wrong shape.
Alternatives:
- (a) Interface only. Define the Protocol method + DB table; service raises NotImplementedError; registry returns provider that supports subscription but the higher-level SubscriptionService is stubbed.
- (b) Full stub against test mode, no callers. Implementation works end-to-end against Stripe test mode but no production traffic.
- (c) Full implementation with first concrete use case. Block this spec until recovery accommodation (or provider listing fees) needs subscriptions.
Recommendation: (a) interface only. No concrete use case yet, but the abstraction shape locks now so that when use case lands, the table + Protocol are already in place. The cost of (a) is ~50 lines of NotImplementedError-raising code; the cost of getting the abstraction wrong is a migration plus a release cycle.
Decision (proposed): (a). Alternatives considered: (b) tempts because it gives us test confidence, but burns Sonnet tokens on code with no consumer. (c) blocks the substrate from shipping — defeats the whole "build the substrate before the first consumer" argument in §2.
Q2 — Migration strategy for existing Stripe code¶
Issue: app/services/payments/ exists, is used by consultation charges + (about to be) quote acceptance. How do we move to commerce without breaking production?
Alternatives: - (a) Strangler pattern. Add commerce alongside; migrate callers one at a time via feature flag; retire legacy after migration complete. - (b) Big-bang. One PR rewrites all callers; old code deleted in same PR. - (c) Facade. commerce/ initially wraps existing code; deepen over time.
Recommendation: (a) Strangler. Aligned with CLAUDE.md "Production-Evolvable MVPs" (no throwaway code) and definition-of-done.md (<500 lines / <10 files per PR). Each caller migrates in its own PR; rollback is one flag flip.
Decision (proposed): (a). Alternatives considered: (b) maximises code-cleanliness but maximises blast radius. (c) sounds clean but ends up with two abstractions in production simultaneously — facade + native — which is worse than (a)'s explicit feature-flag boundary.
Q3 — Refund cascade — sync or async?¶
Issue: when commerce completes a refund, downstream domains (quote, consultation, transport) need to update their own state. Should commerce wait for them?
Alternatives:
- (a) Synchronous callback. Commerce calls into domain services directly. Fast, easy to trace.
- (b) QStash event bus. Commerce emits commerce.refund.completed; domains subscribe.
- (c) Hybrid. Sync for critical-path domains (consultation), async for the rest.
Recommendation: (b) QStash. Matches existing pattern (ADR-0014 — Upstash Workflow for multi-step async). Decouples domains (CLAUDE.md ground rule #7). Failure isolation — a domain handler crashing doesn't roll back the refund.
Decision (proposed): (b). Alternatives considered: (a) is forbidden by domain-boundaries rule — would require commerce to import from quote / consultation domains. (c) adds branching in commerce that doesn't pay for itself.
Q4 — Ledger writes — sync with mutation, or out-of-band on webhook?¶
Issue: when a payment intent state changes, the ledger row should also be written. Where in the flow?
Alternatives:
- (a) Sync-on-mutation. Every state change in PaymentIntentService writes ledger rows in the same DB transaction. Atomic.
- (b) Webhook-driven. Provider webhook arrival is the trigger; mutation just records the optimistic state, ledger waits for webhook confirmation.
- (c) Hybrid. Optimistic ledger on mutation; reconciled / corrected when webhook arrives.
Recommendation: (a) sync-on-mutation. Atomic semantics, simplest reconciliation, easiest debugging. The cost is occasional optimism (we record a capture that the provider may later reject) — handled by ledger reversal entries when the failure webhook arrives. Simpler than (b)'s "is the ledger up to date yet" race.
Decision (proposed): (a).
Note:
PaymentIntentServiceuses the same two-transaction pattern as refunds (§10.3): (Tx 1) insert intent row with status='pending', commit; (provider call outside tx); (Tx 2) write ledger pair + state transition on success, or update intent to failed without ledger row. Async failure (later webhook reports payment_failed) produces areversalledger pair against the captured pair. Never hold a DB transaction open across an external HTTP call to Stripe / Razorpay — connection-pool exhaustion under load.
Alternatives considered: (b) waits for webhook delivery before any ledger row exists; if webhook is delayed, admin can't see the in-flight state. (c) doubles the write logic.
Q5 — Settlement timing — real-time, nightly, weekly?¶
Issue: when does money move from platform to vendor?
Alternatives: - (a) Real-time via destination charges. Money splits at capture in a single provider operation. - (b) Batch nightly. Platform captures all; nightly job creates transfers. - (c) Weekly. Reconciliation + slower transfers.
Recommendation: (a) destination charges by default; (b) batch only when destination not available. Atomic, minimal ledger movements, vendor sees money faster (cash flow benefit). Per-vendor capability is encoded in provider.supports_destination_charges + vendor's connected-account region.
Decision (proposed): (a) default, (b) fallback. (c) not implemented MVP. Alternatives considered: Many marketplaces (e.g. Substack) batch weekly to amortise transfer fees. Curaway's transaction count is low + per-transaction value is high — per-transaction transfer fee is negligible. Real-time wins.
Q6 — Currency conversion timing¶
Issue: patient pays USD, vendor settles INR. The USD/INR rate moves daily. At what time do we pick the rate?
Alternatives: - (a) Quote acceptance time — lock the USD price at quote acceptance. - (b) Settlement time — use the rate of the day money moves. - (c) Hybrid — display the locked rate to patient (trust); settle at actual rate; capture spread.
Recommendation: (c) hybrid. Patient trust requires a locked display rate (no "you'll be charged more if INR moves" surprises). Vendor settlement uses actual day's rate (correctness). Platform absorbs / earns the spread, captured in fx_spread ledger entries. Visible to admin in §11.1.
Decision (proposed): (c). Alternatives considered: (a) exposes platform to FX risk between quote acceptance and capture; (b) breaks patient trust ("why is the charge different from what I agreed to?"). (c) is the marketplace standard.
Q7 — Webhook routing during migration¶
Issue: today's /api/v1/stripe/webhooks exists; commerce wants /api/v1/commerce/webhooks/stripe. Two webhook URLs in production at the same time?
Alternatives: - (a) Keep existing; add commerce path. Both routes coexist during migration window. Stripe's webhook endpoint config in their dashboard points to whichever URL is active for the test/live environment. - (b) Rewrite existing route to live inside commerce. One URL, behaviour changes under the flag. - (c) Hybrid: keep existing URL but route to commerce when flag on.
Recommendation: (a) keep existing for backward compat; add commerce path; deprecate old path after migration complete (PR-10). Lowest blast radius. Stripe / Razorpay dashboard config doesn't change. After PR-10, deprecation window of 30 days with 308 redirect on old URL, then delete.
Decision (proposed): (a). Alternatives considered: (b) requires a Stripe/Razorpay dashboard config change at the same time as a code release — high coordination risk. (c) is technically what (a) becomes after PR-10 (and is the same as (b) from the outside).
Q8 — Tax handling¶
Issue: GST (India), sales tax (US states), VAT (UK), DCT (UAE). Where does tax computation live?
Alternatives:
- (a) Pass-through. Commerce stores tax_amount_minor_units per intent + creates a tax ledger row; computation done by an external tax_service (yet to be built).
- (b) Inline. Commerce computes tax based on tenant + vendor jurisdictions + product category.
- (c) Provider-side. Stripe Tax / Razorpay tax integrations compute and add tax at provider call time.
Recommendation: (a) pass-through for v1. Tax computation is a separate domain — jurisdictional rules, exemption certificates, registration thresholds. Building it inside commerce conflates concerns. Commerce reserves the column + ledger account; a future tax_service will populate. Until then, tax is zero per intent (acceptable for current MVP where cross-border medical-tourism services are often zero-rated or treated as health services exempt from GST/VAT, but jurisdictional analysis is the tax service's job).
Decision (proposed): (a). Alternatives considered: (b) embeds tax logic in commerce that we'd want to factor out later. (c) is appealing (Stripe Tax handles a lot for us) but couples to provider — abstraction broken.
15. Compliance + data governance¶
15.1 GDPR Article 17 erasure¶
Each new commerce table is registered in the existing erasure manifest (app/services/gdpr/erasure.py:ERASURE_HANDLERS):
| Table | Action | Fields touched |
|---|---|---|
commerce_payment_intents |
Anonymise | actor_id → [anonymised] sentinel; metadata_json redacted; raw_provider_payload deleted (set to {}); financial fields PRESERVED for audit |
commerce_ledger_entries |
Anonymise | actor_id, subject_patient_id → ANONYMISED_PATIENT_UUID = '00000000-0000-0000-0000-000000000001'; financial fields PRESERVED (append-only audit) |
commerce_subscriptions |
Anonymise | customer_external_id → [anonymised]; actor_id same; financial fields PRESERVED |
commerce_settlements |
Anonymise actor_id only | financial preserved |
commerce_commission_schedules |
No action | platform-level config, no PII |
commerce_refunds |
Anonymise | actor_id, approved_by_actor_id, reason_note; financial preserved |
commerce_receipts |
Crypto-shred + R2 delete | encryption key for email_sent_to is shredded; pdf_r2_key triggers R2 object delete; receipt row remains with email_sent_to=NULL, pdf_r2_key=NULL, template_version preserved for audit |
Ledger preservation under erasure: financial rows are immutable AUDIT artifacts. Personal identifiers are anonymised; amounts/currencies/timestamps survive. Aligns with Curaway audit-log policy (append-only, surviving erasure with anonymisation).
PR-1a scope: register commerce_payment_intents, commerce_ledger_entries, commerce_commission_schedules with erasure.py. PR-1b: register the other 4 (commerce_refunds, commerce_settlements, commerce_subscriptions, commerce_receipts).
15.2 Sanctions screening (non-goal)¶
See §1.2 — delegated to Stripe Radar / Stripe Identity + Razorpay onboarding KYC.
15.3 DPA / SCC acknowledgement¶
Stripe DPA + Razorpay DPA on file (cross-border processor agreements). Standard Contractual Clauses cover EU patient data → Stripe (US processor). DPDP Act (India) consent basis: legitimate_use for payment processing — declared at checkout consent step (existing). Annual review of DPA scope; commerce changes that materially expand data shared with processors require legal review.
15.4 High-value regulatory flag¶
Settlement-time job flags transactions ≥$10K USD-equivalent for downstream regulatory reporting via the commerce_payment_intents.regulatory_flag column (§4.1). MVP just flags; reporting integrations deferred.
16. Cross-references¶
- ADR-0017 — Multicurrency architecture (currency_service contract)
- ADR-0018 §Phase 4 — Payments + SLA (this spec broadens the §4 scope)
- ADR-0023 — Tenant ID convention (commerce tables follow UUID-PK + slug pattern)
- ADR-0019 — GDPR Erasure Cascade (commerce tables added to the erasure handler — patient_email in receipts encrypted at rest, deletable on erasure request; ledger entries are append-only and survive erasure with patient_id replaced by anonymised marker — same pattern as audit logs)
docs/specs/commission-ledger-feature.md— facilitator commission ledger; commerce uses the same frozen-at-event pattern but for vendor (Stripe Connect / Razorpay Route) flowsdocs/specs/multicurrency-feature.md— currency_service API surfacedocs/specs/transportation-tier-feature.md— Phase 7; commerce's first concrete settlement consumerdocs/api/error-codes.md— newCOMMERCE_*error codes (added in PR-5a)tests/test_route_access_scanner.py— commerce admin routes will need exemption entries since they're admin-scoped, not patient-scoped (no{case_id}/{patient_id}path params)
17. Operations + infrastructure¶
17.1 Error aggregation¶
Curaway has no Sentry integration today (per app/services/external_api_health.py aspirational reference only). Until Sentry is wired, ALL commerce ERROR-level log lines route via app/services/logging.py to a structured-log channel persisted in Postgres (commerce_error_log — admin-queryable via §11.8 webhook viewer UI is reused with entry_type='error' filter; or a new admin screen if scope permits).
CREATE TABLE commerce_error_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(36) REFERENCES tenants(id),
service VARCHAR(60) NOT NULL, -- 'payment_intent' | 'refund' | 'settlement' | 'webhook' | ...
severity VARCHAR(20) NOT NULL CHECK (severity IN ('ERROR', 'CRITICAL')),
event_type VARCHAR(80) NOT NULL, -- 'ledger_imbalance' | 'webhook_signature_invalid' | ...
message TEXT NOT NULL,
stack_trace TEXT,
correlation_id VARCHAR(64),
actor_id VARCHAR(255),
metadata_json JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Telegram alerts (existing pattern via alerting.py) trigger on severity='CRITICAL'. Added to PR-1a scope. If/when Sentry lands, the log table becomes a fallback; commerce keeps the structured-log discipline.
18. Definition of done¶
Tier 3 feature. All sections in .claude/rules/definition-of-done.md apply unless marked N/A in the PR description. Highlights:
- Spec reviewed + approved by SD before any code lands.
- Architecture review required on:
- PR-1a (schema lock for payment_intents, ledger, commission_schedules)
- PR-1b (schema lock for refunds, settlements, subscriptions, receipts, settlement_intents, webhook_log)
- PR-3 (Protocol contract — defines DTO shape that locks for all future providers)
- PR-5a (state machine + webhook router authz model)
- Per-PR: feature flag, alembic migration, unit tests, integration tests, no cross-domain imports, repository pattern enforced, tenant_id everywhere, money in minor units + ISO 4217.
- PR-5a + later: Langfuse traces N/A (no LLM calls). Telegram alerts wired on failure paths (settlement failed, refund failed, webhook signature invalid > threshold).
- Admin UI (PR-8): WCAG AA, mobile breakpoints, Curaway brand tokens, multi-portal consistency.
- Docs site (this spec) added to
mkdocs.ymlnav alphabetically under Feature Specs. - CLAUDE.md update (PR-1a): change the line "All money in USD cents + ISO 4217" under Architecture Summary → Data Patterns to "All money in smallest currency unit + ISO 4217". This is a 1-line doc edit that aligns the ground rule with multi-currency reality.
- Deployment: Railway env vars added (full list):
STRIPE_API_KEY(existing, restricted-key recommended for commerce reads)STRIPE_CONNECT_PLATFORM_ACCOUNT_ID(this spec)STRIPE_WEBHOOK_SECRET(this spec — also dual rotation:STRIPE_WEBHOOK_SECRET_NEWduring rotation)STRIPE_CONNECT_WEBHOOK_SECRET(separate from main webhook secret)STRIPE_API_VERSION(pinned, e.g."2024-12-18.acacia")RAZORPAY_KEY_ID(existing)RAZORPAY_KEY_SECRET(existing)RAZORPAY_WEBHOOK_SECRET(this spec)RAZORPAY_ROUTE_PLATFORM_ACCOUNT_ID(this spec)R2_RECEIPTS_BUCKET(this spec)R2_RECEIPTS_REGION(this spec)COMMERCE_IDEMPOTENCY_SALT(this spec — 32-byte hex)COMMERCE_IDEMPOTENCY_SALT_NEW(rotation; optional, populated only during 30-day rotation window)COMMERCE_FAILURE_REASON_REDACTION_ENABLED(Flagsmith — emergency disable if mis-redaction)
Last updated: 2026-05-13 — initial draft for SD review.