Skip to content

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_units field on each charge; computation done by a future tax_service (out of scope this spec).
  • Multi-leg payments / split capture at MVP. Each commerce_payment_intent row 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_service spot 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_json for 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):

  1. 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_intent abstraction without leakage. Either the one-time path grows subscription branches (every domain gets if recurring: ... checks) or the subscription path becomes a second abstraction next to the first. Build both behind the same protocol at the start.
  2. 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.
  3. 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.
  4. 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 CommerceReceiptPayload DTO.
  • Currency rate caching. currency_service owns. Commerce calls currency_service.convert(amount_minor_units, from_currency, to_currency).
  • Provider routing rules. Existing app/services/payments/routing.py:pick_payment_provider continues 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 one
  • app/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 on commerce_payment_intents.status — derive from commerce_refunds aggregation 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: - pendingtrialing (on Stripe customer.subscription.trial_will_end precursor; first invoice succeeds with trial active) - pendingactive (on first successful invoice, no trial) - pendingcancelled (admin cancels before first invoice, OR first-invoice failure terminal) - trialingactive (trial ends, first paid invoice succeeds) - trialingcancelled (admin or customer cancels during trial) - activepast_due (invoice failure; retries pending) - past_dueactive (retry succeeds) - past_duecancelled (retry retries exhausted) - activepaused (admin pauses) - pausedactive (admin resumes) - activecancelled (admin or customer cancels — immediate or cancel_at_period_end=true) - cancelledexpired (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): wraps stripe.PaymentIntent, stripe.Subscription, stripe.Refund, stripe.Transfer. Supports destination charges (transfer_data={"destination": acct_xxx, "amount": platform_fee}). Webhook signature via stripe.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 Razorpay Order, Subscription, Refund, Transfer. Route splits via transfers array on Order. Webhook signature via razorpay.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

POST /api/v1/commerce/webhooks/{provider}

Path param: stripe | razorpay. Body: raw bytes (no FastAPI Pydantic parsing — we need the exact bytes for signature verification).

Handler flow:

  1. Look up provider adapter via get_provider(name).
  2. Call provider.webhook_verify(payload, signature_header, tolerance_seconds=300). For Stripe, this passes tolerance=300 to stripe.Webhook.construct_event (rejects events whose embedded timestamp is older than 5 minutes). For Razorpay, the adapter checks (provider_event_id, occurred_at) against webhook_events: reject if now() - occurred_at > 7 days (Razorpay event retention window). Raise WebhookSignatureExpired (HTTP 400) on stale events even if the cryptographic signature is valid. Raise InvalidWebhookSignature (HTTP 400) on signature mismatch.
  3. Call provider.webhook_parse(payload) — returns CommerceWebhookEvent (canonical type: event_id, event_type from a closed enum, payment_intent_provider_id, raw payload).
  4. Insert / upsert into webhook_events table (existing table — global idempotency log; reuse the existing (provider_name, provider_event_id) UNIQUE constraint).
  5. If duplicate → return 200 immediately (already processed).
  6. Route by canonical event_type to a handler in commerce/webhook_handlers.py. Handlers write ledger rows, update intent state, emit downstream events (Q3 — sync to start, QStash later).
  7. Mark webhook_events.processed = true in 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 through app/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 for payment_intent_ids that exist in commerce_payment_intents. If a webhook references a provider intent that's not in commerce, it gets 404 from 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:

  1. PaymentIntentService.create_intent(...) records currency='USD', vendor_currency='INR'.
  2. At capture, SettlementService.create_settlement(...) calls currency_service.convert(usd_minor_units, 'USD', 'INR', as_of=now()) and gets back (inr_minor_units, fx_rate).
  3. Settlement row stores fx_rate + fx_source='currency_service_spot'.
  4. Ledger gets three pairs (per §4.2):
  5. Patient charge: +USD → platform_pass_through USD
  6. Settlement: platform_pass_through USD → vendor_payable INR
  7. 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_id UUID. Admin views (§11.1) can group by fx_link_id to 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 subtracts flat_fee_minor_units regardless of amount.
  • percentage: subtract amount_minor_units * percentage_bps / 10000. Basis points so we have 0.01% granularity without floats.
  • tiered: tiered_config_json is a list of {up_to_minor_units, bps} tiers. The transaction amount falls into one tier and uses its bps. Optional final tier with no up_to is 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):

  1. If intent_override_minor_units is 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.
  2. Else query for the active schedule per §4.5 selection rule.
  3. Compute fee from shape; clamp fee <= amount_minor_units (never negative net to vendor).

  4. 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=true flag

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 reversal entry 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):

  1. Resolve source_domain + source_id → underlying case_id via the appropriate domain service:
  2. source_domain='quote_acceptance'quote_service.get_case_id_for_quote(source_id)
  3. source_domain='consultation_charge'consultation_service.get_case_id_for_charge(source_id)
  4. source_domain='transport_booking'transport_service.get_case_id_for_booking(source_id)
  5. source_domain='admin_adhoc' → no case_id; route requires commerce:refund:create + admin:force permissions. caller_idempotency_key body must include case_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 (severity info) to the compliance channel with actor_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.

  1. Validate intent exists, status ∈ {captured, settled, partially_refunded}.
  2. Compute remaining_refundable = intent.amount_minor_units - sum(successful_refunds.amount). Refund amount must be > 0 AND <= remaining.
  3. Generate idempotency key: hash(payment_intent_id + ':refund:' + caller_idempotency_key). The caller MUST pass caller_idempotency_key as a UUID v4 in the request body (or HTTP X-Idempotency-Key header 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.
  4. Two-transaction pattern (avoids holding DB tx across HTTP):
  5. Tx 1: insert commerce_refunds row with status='pending', commit immediately. The pending row + idempotency key protects against double-charge on retry.
  6. Provider call: provider.refund(provider_intent_id, amount, idempotency_key, reason, metadata) runs OUTSIDE any open DB transaction.
  7. Tx 2: on success → write ledger pair + update status='succeeded' + emit event, commit. On failure → update status='failed' + record failure_reason, no ledger write, commit.
  8. Two-eye invariant: the service rejects any submission where actor_id == approved_by_actor_id. This prevents an admin holding both :create and :approve permissions from self-approving in one session. Enforce at the service layer; tests in PR-5b + PR-7 cover the violation case.
  9. On success: status='succeeded', write ledger pair (reverses patient charge), emit commerce.refund.completed.
  10. 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_id to 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 authorized and 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 paid without a provider transfer (for cases where settlement happened via wire transfer outside the platform). Writes a mechanism='manual' ledger entry; requires reason text + second approver.
  • Override validation: on override submission, SettlementService.validate_external_transfer(provider, provider_transfer_id) calls provider.fetch_transfer(transfer_id) and verifies: (a) transfer exists, (b) destination matches vendor account, (c) amount matches vendor_net_minor_units ± 1 minor unit (rounding tolerance), (d) currency matches. Validation failure rejects override with HTTP 422 + error code COMMERCE_005. For mechanism='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_to set to now, new row's effective_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 requires commerce: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 :create and :approve permissions 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:approve permission before the platform allows export. Evidence files have an automated PII/PHI scanner pre-upload that blocks ICD-, 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-Key HTTP header on every PaymentIntent / Refund / Transfer call.
  • Razorpay: idempotency_key param 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_idcommerce_refunds(id) and commerce_ledger_entries.settlement_idcommerce_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.pyrecord_charge, record_refund, record_settlement, record_fx_spread, record_reversal, search, aggregate_by_account. Append-only invariant enforced. - app/services/commerce/commission.pyresolve_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.pyregister_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.pycreate_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.pyPOST /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.pyrefund, emits commerce.refund.completed. - app/services/commerce/settlement.pycreate_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.pyPOST /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: PaymentIntentService uses 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 a reversal ledger 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_idANONYMISED_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) flows
  • docs/specs/multicurrency-feature.md — currency_service API surface
  • docs/specs/transportation-tier-feature.md — Phase 7; commerce's first concrete settlement consumer
  • docs/api/error-codes.md — new COMMERCE_* 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.yml nav 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_NEW during 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.