TorbatYar/docs/architecture/adr/ADR-021.md
Mortezakoohjani 9fac160258 feat(payment): ship Torbat Pay MVP phases 14.0-14.5
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>
2026-07-27 17:57:04 +03:30

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:

  1. L1 must pass before any Payment API mutates state (except health/capabilities discovery when documented).
  2. L2 gates API surfaces, permissions exposure, and /capabilities flags — hidden bundles must not expose routes, menus, or permission leaves (same rule as ADR-017).
  3. L3 gates optional engines without new Core plan features.
  4. 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 + workspace enabled + 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 + PaymentProviderAssignmentno 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_ref or payment_workspace_ref UUIDs only — never PSP merchant IDs from gateways.
  • Torbat Pay Merchant mode uses platform-scoped facilitator credentials + tenant sub-merchant identifier; BYO mode uses tenant PspConnection only.

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 agreement
  • credit_authorization_reference — authorization/hold id from credit provider
  • installment_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) publish payment.* 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: managecreate|update|deleteview. 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 before payment-14.0 code
  • International PSPs remain adapter-only until explicitly phased

Alternatives Considered

  1. Core-only entitlement (no Payment bundles) — rejected; cannot express Torbat Pay commercial packs or hide split/refund APIs independently of Core plans.
  2. PSP credentials in Core — rejected; violates ADR-020 and PCI isolation.
  3. Vertical-owned checkout contracts — rejected; duplicates Marketplace/Hospitality/Sports integrations.