Add independent payment-service (port 8012, payment_db) with foundation licensing, BYO-PSP, merchant accounts, idempotent requests, callbacks, and immutable ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
12 KiB
ADR-021: Payment Platform Licensing, Routing & Integration Contracts
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-07-27 |
| Deciders | Platform Architecture |
| Supersedes | — |
| Superseded by | — |
| Extends | ADR-020 |
Context
ADR-020 establishes Torbat Pay as an independent service. Before Phase payment-14.0 implementation, the platform must define non-breaking contracts for:
- Tenant enable/disable of Payment without vertical code changes
- Core service entitlements vs Payment bundle licensing vs operational feature toggles
- BYO-PSP vs Torbat Pay Merchant mode selection and provider reassignment
- Versioned API/event contracts for payment intents, checkout, callbacks, settlement, and future refund/split/subscription/wallet/installment flows
- Compatibility with Core, Identity, Tenant, Accounting, CRM, Communication, Hospitality, Delivery, Sports Center, Marketplace, and future consumers
Hospitality (ADR-017) and Experience (ADR-016) already use bundle licensing + feature toggles + Core entitlement coordination. Payment must follow the same three-layer model to avoid rework.
Decision
1. Three-layer access control (mandatory)
| Layer | Owner | Purpose | Example keys |
|---|---|---|---|
| L1 — Core entitlement | Core Platform | Commercial plan / tenant module enablement | payment.module.enabled |
| L2 — Payment bundle | Payment (payment_db) |
Licensed capability packs within Payment | byo_psp_basic, torbat_pay_merchant, marketplace_splits |
| L3 — Payment feature toggle | Payment (payment_db) |
Operational flags within an active bundle | refunds_enabled, installments_enabled |
Rules:
- L1 must pass before any Payment API mutates state (except health/capabilities discovery when documented).
- L2 gates API surfaces, permissions exposure, and
/capabilitiesflags — hidden bundles must not expose routes, menus, or permission leaves (same rule as ADR-017). - L3 gates optional engines without new Core plan features.
- Payment listens to Core
feature_access.changed(event) to invalidate entitlement cache; Payment does not compute Core plans locally.
2. Tenant payment workspace (enable / disable)
Each tenant has at most one PaymentWorkspace in payment_db:
| Field | Purpose |
|---|---|
status |
disabled | enabled | suspended |
default_payment_mode |
byo_psp | torbat_pay_merchant |
core_entitlement_ref |
Opaque ref to Core feature/subscription check (no FK) |
- Enable Payment: Core L1
payment.module.enabled+ workspaceenabled+ at least one L2 bundle active. - Disable Payment: workspace
disabled— reject new payment requests; in-flight callbacks still processed (14.4+). - Mode switch: update
default_payment_mode+PaymentProviderAssignment— no vertical or adapter code change; routing policy resolves provider at runtime.
3. Provider assignment & routing (no code change on switch)
| Aggregate | Role |
|---|---|
PspProviderRegistration |
Platform catalog of adapter types (ZarinPal, IDPay, …) |
CreditProviderRegistration |
Platform catalog of external credit/financing adapters (Torbat Credit, future third-party BNPL) — registration shell only until phased |
PspConnection |
Tenant BYO-PSP credential binding |
CreditProviderConnection |
Tenant connection to external credit provider (future; same assignment pattern as PSP) |
MerchantAccount |
Torbat Pay Merchant sub-merchant (facilitator mode) |
PaymentProviderAssignment |
Active provider binding for a workspace/branch/context |
PspRoutingPolicy |
Priority, failover, amount/currency/channel/payment_source rules |
Tenants change PSP or credit provider by updating assignment + routing policy only. Adapters are selected by provider_code from registry — adding Torbat Credit or a new PSP is a new adapter registration, not a vertical change.
Payment source types (routing dimension, reserved in v1):
payment_source |
Meaning | Provider adapter |
|---|---|---|
PSP |
Standard gateway capture (BYO-PSP) | PspAdapter |
MERCHANT_FACILITATOR |
Torbat Pay Merchant mode | Facilitator adapter |
CREDIT_PROVIDER |
External credit/BNPL/financing authorization | CreditProviderAdapter (not implemented; architecture reserved) |
Payment MUST invoke Torbat Credit through the same provider adapter pattern as PSPs (initiate, verify, callback hooks on CreditProviderAdapter). Payment stores opaque refs only — never credit scores, KYC payloads, or installment calculation logic.
4. Merchant ownership
- MerchantAccount and facilitator settlement profiles are owned solely by Payment.
- Verticals store
merchant_account_reforpayment_workspace_refUUIDs only — never PSP merchant IDs from gateways. - Torbat Pay Merchant mode uses platform-scoped facilitator credentials + tenant sub-merchant identifier; BYO mode uses tenant
PspConnectiononly.
5. Versioned integration contracts (stable v1 before implementation)
All cross-service payment integration uses versioned contracts documented in payment-contracts.md:
| Contract | Version | Phase introduced | Breaking change policy |
|---|---|---|---|
| PaymentIntentContract | v1 | 14.3 | Additive fields only in v1.x; v2 requires new phase |
| CheckoutSessionContract | v1 | 14.8 | Same |
| CallbackIngressContract | v1 | 14.4 | Same |
| SettlementIntentContract | v1 | 14.9 | Same |
| RefundIntentContract | v1 | 14.6 | Reserved in v1 schema |
| SplitAllocationContract | v1 | 14.7 | Reserved in v1 schema |
| SubscriptionBillingContract | v1 | Future | Schema slot in reference doc |
| WalletTopUpContract | v1 | Future | Loyalty consumer; Payment captures only |
| InstallmentPlanContract | v1 | Future (Payment) | Payment-side schedule shell only — see §12 |
| CreditAuthorizationContract | v1 | Future (Payment ↔ Credit) | Schema slot; authorization refs only |
Verticals depend on contract version + capability flags, not Payment internal models.
12. Torbat Credit — reserved independent service (architecture only)
A future independent service is reserved:
| Field | Value |
|---|---|
| Service id | credit |
| Commercial product | Torbat Credit |
| Database | credit_db (reserved) |
| Event namespace | credit.* (reserved for Torbat Credit — Payment does not publish credit.*) |
Boundary — InstallmentPlan ≠ Credit Engine:
| Concern | Owner |
|---|---|
| Installment schedule shell linked to a payment request (due dates, amounts, linkage refs) | Payment (InstallmentPlanContract v1) |
| Installment calculation, credit scoring, KYC/KYB, BNPL underwriting, financing agreements, collections | Torbat Credit (credit service) — exclusive |
Payment MAY store opaque architectural reference fields on intents and transactions:
credit_provider_id— registry id of external credit provider (e.g. Torbat Credit adapter)financing_reference— opaque id returned by credit provider for a financing agreementcredit_authorization_reference— authorization/hold id from credit providerinstallment_contract_reference— link to Torbat Credit contract; not Payment-owned installment math
Integration pattern: Torbat Credit is consumed by Payment as an external Credit Provider — same adapter registry, routing policy, and assignment mechanics as PSPs. No shared database. No credit domain logic inside Payment.
Implementation status: Architecture references only after payment-arch patch. No credit service code, no CREDIT_PROVIDER routing execution, no credit.* event producers until Torbat Credit is registered in a future phase.
6. API-first and event-first (mandatory)
- API-first: All vertical actions go through documented REST (or internal token-gated) endpoints; no shared DB reads.
- Event-first: State changes that verticals care about (
paid,failed,refunded,settled) publishpayment.*events via transactional outbox (ADR-006). Verticals may use API poll or event subscribe — never both as source of truth without idempotency keys.
7. Database-per-service (ADR-001)
Payment is sole owner of payment_db. Forbidden: cross-DB FKs, importing vertical models, storing vertical order rows in Payment.
8. Permission tree
Prefix payment.*. Permission leaves are bundle-gated (L2). Inheritance: manage ⊃ create|update|delete ⊃ view. Service-to-service scopes use internal tokens with payment.requests.create etc.
Foundation permission catalog registered in Phase 14.0; leaves grow per phase without renaming roots (additive only).
9. Bundle isolation
Inactive bundles:
- Return 404 or 403 (consistent per API template) for bundle-gated routes
- Omit from
/capabilities - Hide permission leaves from catalog export
- Do not publish bundle-specific events
10. Multi-tenant isolation (ADR-003)
Every business row includes tenant_id. Callback URLs include tenant-routable connection id; verification rejects cross-tenant callback routing. Internal service calls require X-Tenant-ID + scoped token.
11. Service compatibility (read-only integration)
| Service | Integration | Payment must not |
|---|---|---|
| Core | Entitlement check API; feature_access.changed |
Own plans/subscriptions |
| Identity | user_ref / payer_ref on intents |
Admin users or OIDC |
| Tenant (Core) | Workspace tied to Core tenant id | Duplicate tenant registry |
| Accounting | SettlementIntentContract events/API | Create JournalEntry |
| CRM | contact_ref, organization_ref on payer |
Own CRM aggregates |
| Communication | Receipt/refund notify client | Own SMS/email providers |
| Hospitality | Checkout refs from POS/QR; subscribe to payment.request.paid |
Own pos_payment PSP fields long-term |
| Delivery | Optional COD payment ref | Own delivery settlement |
| Sports Center | Membership/competition payment refs | Own membership billing |
| Marketplace | Checkout + split refs (14.7+) | Own vendor catalog |
| Experience | Paid form/checkout widget refs | Own payment capture |
| Loyalty | WalletTopUpContract (future) | Own wallet ledger |
| Torbat Credit (future) | CreditAuthorization via CreditProviderAdapter; subscribe to credit.* |
Own scoring, KYC, BNPL, financing engine |
No existing service code is modified during architecture-only phases; verticals adopt Payment client contracts in their own future connector phases.
Consequences
Positive
- Tenants can enable/disable Payment, switch PSP, and switch BYO vs Merchant mode without redeploying verticals
- Future refund/split/subscription/wallet/credit-provider flows extend v1 contracts without breaking checkout integrations
- Torbat Credit reserved as external provider — Payment can route like any PSP when implemented
- Clear compatibility matrix for all platform services
Negative
- Three-layer gating adds checks on every mutating API
- Contract documentation must stay synchronized with
/capabilities
Neutral
- Architecture phase (
payment-arch) completes beforepayment-14.0code - International PSPs remain adapter-only until explicitly phased
Alternatives Considered
- Core-only entitlement (no Payment bundles) — rejected; cannot express Torbat Pay commercial packs or hide split/refund APIs independently of Core plans.
- PSP credentials in Core — rejected; violates ADR-020 and PCI isolation.
- Vertical-owned checkout contracts — rejected; duplicates Marketplace/Hospitality/Sports integrations.