Skip to content

Repository files navigation

StableRail

StableRail is a Go reference implementation of a provider-neutral payment orchestration platform. It exposes one HTTP payment model for inbound pay-ins and outbound payouts, keeps payment, double-entry ledger, outbox, and saga state in PostgreSQL, and uses Kafka to coordinate policy checks, accounting, provider execution, compensation, and manual review. Settlement-provider adapters implement the external pay-in and payout operations without changing the public payment API.

Highlights

  • Separate payment, settlement, ledger, and reconciliation statuses so business outcomes do not obscure operational or accounting state
  • Immutable, balanced double-entry ledger postings
  • Transactional outbox and inbox with retries, dead-lettering, and redrive
  • Persisted sagas with timeouts, provider returns, compliance holds, and audited manual review
  • Versioned event payloads with consumer upcasting
  • Tenant API keys stored as hashes and HTTP idempotency backed by PostgreSQL
  • Signed tenant webhooks with retry and delivery history
  • Health, readiness, Prometheus metrics, structured logs, and graceful shutdown

Architecture

StableRail currently runs as one process, while preserving boundaries that allow its components to be deployed separately later.

flowchart LR
    Client[Client] --> API[Payment API]
    Operator[Operator] --> API
    API --> DB[(PostgreSQL)]
    DB --> Relay[Outbox relay]
    Relay --> Kafka[[Kafka]]
    Kafka --> Consumers[Inbox consumers]
    Consumers --> Saga[Saga and workers]
    Saga --> DB
    Saga <-->|Generic pay-in/payout contract| Provider[Settlement provider]
    Provider --> External[External provider adapter]
    External -->|Signed webhook| API
    DB --> Webhooks[Tenant webhooks]
Loading

Every business update and outgoing event commits in one PostgreSQL transaction. Consumers record an event in their inbox and apply its effects in another single transaction. Delivery is at least once; deterministic event IDs, uniqueness constraints, and inbox records make retries safe.

Package ownership

Package Responsibility
paymentapi HTTP transport, authentication, and tenant/operator endpoints
paymentcore Shared payment, funds, quote, execution, refund, history, and direction-neutral payment-query models
paymentcore/payin Inbound quote and operation model, saga coordination, provider execution, and result persistence
paymentcore/payout Outbound creation, refunds, quotes, saga coordination, provider execution, and result persistence
ledger Transactional double-entry payout reservation, settlement, and release; inbound pay-in receipts; and provider-return journals
policy Payment policy evaluation contracts
reconciliation Comparison of payment, ledger, and provider records plus discrepancy resolution
eventbus Shared event envelopes, topic and version contracts, and Kafka producer/consumer adapters
outbox Transactional event publication, retry and dead-letter handling, and operator redrive
settlement Provider-neutral payout/pay-in contracts and the deterministic mock provider
settlement/blindpay BlindPay client, resource mapping, quote/execution adapters, and webhooks
workers Policy, ledger, and provider command execution plus runtime timeout polling for both settlement directions

paymentcore owns the shared merchant-facing payment lifecycle, with payin and payout handling their direction-specific workflows. eventbus defines shared events and routing, workers executes asynchronous work, and settlement providers implement separate quote and execution capabilities for each supported direction.

Pay-in and payout services manage quotes and provider execution. Their saga coordinators retry uncertain operations with stable idempotency keys. Provider adapters translate external API responses and webhooks into normalized results; payment, ledger, and merchant-notification updates remain owned by StableRail.

Kafka topics

All application topic names are defined in eventbus/topics.go. Pay-in and payout topics carry internal workflow events; payment-events carries the stable merchant-facing lifecycle shared by both directions.

Topic Constant Producers Consumers Purpose
payout-events eventbus.PayoutEventsTopic Payout services, workers, and provider webhooks Payout saga coordinator Internal payout workflow facts
payin-events eventbus.PayinEventsTopic Pay-in services, workers, and provider webhooks Pay-in saga coordinator Internal pay-in workflow facts
payment-events eventbus.PaymentEventsTopic Pay-in and payout workflows Tenant-webhook dispatcher Merchant-facing payment lifecycle facts
settlement-commands eventbus.SettlementCommandsTopic Pay-in and payout saga coordinators Policy, ledger, and provider command workers Durable workflow commands for both settlement directions
stablerail-dead-letter eventbus.DeadLetterTopic Outbox relay Operator inspection and redrive tooling Events that exhausted outbox publication retries or exceeded the retry age

Workflow events use direction-specific names such as payout.created, payout.provider_completed, payin.created, and payin.received. Merchant integrations receive only the shared payment.created, payment.processing, payment.succeeded, and payment.failed events.

Payment lifecycle

The API exposes payment_status as the public business outcome:

stateDiagram-v2
    state "Payment status" as payment {
        [*] --> created
        created --> processing: submitted
        created --> failed: rejected before submission
        processing --> succeeded: recipient paid
        processing --> failed: settlement unsuccessful
    }
Loading

Operational and accounting state stays with the subsystem that owns it:

State Owner Meaning
payment_status payments Public business outcome: created, processing, succeeded, or failed
settlement_status payins / payouts Normalized external movement state, including uncertainty, holds, receipt/completion, failure, and return
ledger_status ledger_journals Accounting recognition: pending, posted, or failed
reconciliation_status payins / payouts Agreement with external records: unmatched, matched, or exception

Balances, reservations, and availability are calculated from posted ledger_entries; they are not mutable payment attributes. An ambiguous provider submission remains payment_status=processing with payouts.settlement_status=unknown, while the posted reservation journal continues to determine availability until a retry, webhook, or reconciliation establishes the outcome.

Post-success returns

A bank or provider can return funds after a payout was already confirmed. StableRail records that as a separate financial operation:

payment_returns contains only completed provider returns; it is an immutable record rather than a return lifecycle. Its required journal debits cash:operating for the asset received back and credits settlement:payable to restore the obligation. The original payment is not rewritten. StableRail emits payment.return.succeeded; return details are read from the separate return operation rather than encoded as payment state.

Merchant-issued refunds are separate linked payments. POST /v1/payments/{id}/refunds accepts an idempotency key, a positive amount, a reason, and an optional fresh payout_quote_id for BlindPay routing. Partial refunds are supported up to the original payment amount. StableRail creates a new payment and uses its ID as payment_refunds.refund_payment_id; original_payment_id links back to the payment being refunded. It binds the payout quote when supplied and emits payout.created for workflow coordination and payment.created for merchant notification. From there, policy, ledger reservation, settlement, and failure handling use the ordinary payout workflow. Refund eligibility is established by the original payment outcome and its posted settlement journal. Provider-originated returns remain separate and continue to use payment_returns and reversal accounting.

Payout saga lifecycle

The persisted payout saga tracks internal workflow progress in more detail than the public payment status:

stateDiagram-v2
    state "funds_returned (saga)" as returned
    [*] --> awaiting_policy: payout.created
    awaiting_policy --> awaiting_ledger: payout.policy.approved / ledger.reserve
    awaiting_policy --> failed: payout.policy.rejected / payment.fail
    awaiting_ledger --> awaiting_settlement: payout.funds_reserved / settlement.execute
    awaiting_ledger --> failed: payout.ledger_failed / payment.fail

    awaiting_settlement --> settling_payment: payout.provider_completed / payment.settle
    settling_payment --> completed: payout.completed

    awaiting_settlement --> awaiting_settlement: settlement timeout / retry settlement.execute
    awaiting_settlement --> failed: payout.provider_failed / payment.fail_reserved
    awaiting_settlement --> failed: submission_failed / payment.fail

    awaiting_settlement --> returning: payout.provider_returned / ledger.release
    failed --> returning: late payout.provider_returned / ledger.release
    returning --> returned: payout.funds_released / payment.return

    awaiting_settlement --> on_hold: payout.on_hold
    on_hold --> settling_payment: payout.provider_completed / payment.settle
    on_hold --> failed: payout.provider_failed / payment.fail_reserved
    on_hold --> returning: payout.provider_returned / ledger.release
    on_hold --> manual_review: compliance timeout
    manual_review --> on_hold: operator retry
    manual_review --> settling_payment: operator complete / payment.settle
    manual_review --> failed: operator fail / payment.fail_reserved
    manual_review --> returning: operator return / ledger.release
Loading

The saga's funds_returned label is workflow state, not a payment status. The resulting payment remains payment_status=failed; the posted release journal records the accounting effect. The transition from failed handles a provider return that arrives after a reservation-related failure was already recorded.

Timeout handling is conservative: settlement timeouts retry the idempotent provider command while preserving the reservation, compliance timeouts require manual review, and ambiguous submissions remain in processing until a retry, webhook, or reconciliation establishes an outcome. A reservation becomes available only after a confirmed pre-capture failure, and becomes returned only after the provider confirms the funds came back.

Pay-in saga lifecycle

The pay-in coordinator uses the same settlement_sagas table with direction=payin, but has direction-specific states and commands:

stateDiagram-v2
    [*] --> awaiting_policy: payin.created
    awaiting_policy --> awaiting_execution: payin.policy.approved / payin.execute
    awaiting_policy --> failed: rejection or timeout / payin.fail
    awaiting_execution --> processing: payin.processing
    awaiting_execution --> on_hold: payin.on_hold
    awaiting_execution --> awaiting_ledger: payin.received / payin.ledger.record
    awaiting_execution --> awaiting_execution: execution timeout / retry payin.execute
    processing --> processing: provider polling or retry
    processing --> on_hold: payin.on_hold
    processing --> awaiting_ledger: payin.received / payin.ledger.record
    on_hold --> awaiting_ledger: payin.received / payin.ledger.record
    on_hold --> failed: compliance timeout / payin.fail
    awaiting_ledger --> completed: payin.succeeded
    awaiting_ledger --> awaiting_ledger: ledger timeout / retry payin.ledger.record
    awaiting_ledger --> failed: payin.failed
    awaiting_execution --> failed: payin.failed
    processing --> failed: payin.failed
    on_hold --> failed: payin.failed
    awaiting_execution --> refunded: payin.refunded
    processing --> refunded: payin.refunded
    on_hold --> refunded: payin.refunded
    awaiting_ledger --> refunded: payin.refunded
    completed --> refunded: payin.refunded / reverse ledger
Loading

Pay-in policy and compliance waits fail on timeout. Provider execution and ledger commands are retried with their original idempotent operation identity. Each active pay-in state stores a deadline, and the timeout worker claims only direction=payin rows; the payout timeout worker similarly claims only direction=payout rows.

Pay-ins and payouts

Pay-ins and payouts are directions of the same public payment resource. Create a direction-aware quote with POST /v1/payment-quotes when the client needs to preview and lock pricing, FX, fees, or routing, then create the operation with POST /v1/payments using its quote_id. Both directions also support direct creation. A direct pay-in supplies amount, currency, funding method, and destination account; a direct payout additionally supplies source account and destination instrument. StableRail creates and persists an implicit provider quote before accepting either direct payment, so provider execution always uses a durable provider_quote_id. Creating either direction persists a created payment and transactional outbox events. A dedicated pay-in coordinator sends payin.execute through Kafka only after payin.policy.evaluate is approved, and the command worker performs the provider call. Provider confirmation moves the pay-in to received; the saga then sends payin.ledger.record, and only a successful balanced journal advances the pay-in to succeeded. Provider instructions such as an ACH memo, bank details, Pix code, or CLABE become available asynchronously; retrieve current state with GET /v1/payments/{id}.

Both direction-specific coordinators store orchestration state in settlement_sagas, keyed by payment ID and direction. The provider-facing payins and payouts tables remain separate because their execution details and provider statuses differ; they are not separate public API resources.

Direction-specific quote boundaries use opaque StableRail resource IDs resolved through provider_resources; provider wallet and bank-account identifiers do not appear in the shared payment API. Both execution boundaries receive only a stable idempotency key and provider quote ID. Verified payin.* webhooks can advance an executed pay-in to processing, on_hold, received, failed, or refunded. The saga turns received into succeeded only after the ledger command debits cash:operating and credits settlement:payable. A refund after that successful journal posts the inverse journal; a refund before ledger completion has no completed pay-in journal to reverse. Early webhooks are retained and reconciled after the local pay-in becomes visible.

Pay-in and payout records own their source, destination, method, and monetary snapshot. A quote locks fees, FX, provider routing, and source/destination amounts. Clients may supply an explicit quote or use direct creation, but StableRail persists a quote in both cases and provider execution always references it. Accepting the quote copies its terms into the direction-specific operation snapshot.

Provider resources

Shared APIs and workflow tables use provider-resource IDs instead of naming provider-specific bank-account or wallet fields:

Payout: source account -> destination payment instrument
Pay-in: optional source payment instrument -> destination account

An account represents a balance-holding resource, such as a managed wallet. A payment instrument represents an external routing endpoint, such as a bank account or blockchain address. provider_resources maps those stable IDs to a provider and its reference. For example:

acct_123       -> blindpay / managed wallet / bl_...
instrument_456 -> blindpay / bank account / ba_...

Callers must treat resource IDs as opaque; the current BlindPay reference sync may reuse a provider reference as the local resource ID for compatibility, but the adapter still resolves it through provider_resources. Adding another provider requires new resource mappings and an adapter, not new columns in payins, payouts, or their quote tables. Raw provider responses are isolated in provider_payload, while raw webhook events remain adapter-owned data.

Database schema

The schema separates payment workflow state, provider execution, double-entry accounting, event delivery, and reconciliation while linking each operation back to its payment.

StableRail database schema

Database migrations

Migrations are ordered by dependency. Provider-neutral platform and workflow schema is created before the BlindPay-specific adapter schema.

Migration Main purpose
001_payment_core.sql Payments, shared quotes and provider resources, audit/timeline history, and refunds
002_eventing.sql Transactional outbox and consumer inbox
003_ledger.sql Accounts and balanced payment journals
004_payouts.sql Payout provider operations and post-success returns backed by ledger journals
005_payins.sql Pay-in provider operations linked to payments
006_payment_workflow.sql Direction-aware settlement sagas, manual review actions, and settlement submission records
007_webhooks.sql Merchant webhook delivery and provider webhook ingestion/application tracking
008_reconciliation.sql Reconciliation runs and discrepancies
009_tenant_access.sql Tenant API-key authentication
010_blindpay.sql BlindPay-owned customers, bank accounts, wallets, and raw webhook events

Quick start

Requirements: Go, Docker, and Docker Compose.

Start PostgreSQL and Kafka:

docker compose up -d
docker compose ps

Apply migrations:

for migration in migrations/*.sql; do
  docker compose exec -T postgres psql -U stablerail -d stablerail -f - < "$migration"
done

Create Kafka topics:

for topic in payout-events payin-events payment-events settlement-commands stablerail-dead-letter; do
  docker compose exec kafka /opt/kafka/bin/kafka-topics.sh \
    --bootstrap-server localhost:9092 \
    --create --if-not-exists \
    --topic "$topic" \
    --partitions 1 \
    --replication-factor 1
done

Run StableRail:

export STABLERAIL_DATABASE_URL=postgresql://stablerail:stablerail@localhost:5432/stablerail
export STABLERAIL_KAFKA_BROKERS=localhost:9092
export STABLERAIL_OPERATOR_TOKEN='replace-with-a-secret-token'
go run ./cmd/stablerail

The API listens on :8080. Create a tenant API key:

curl -X POST http://localhost:8080/v1/operator/tenants/tenant-1/api-keys \
  -H "Authorization: Bearer $STABLERAIL_OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"local development"}'

Save the returned srk_... value as STABLERAIL_API_KEY. For the mock provider, seed provider-neutral development resources:

docker compose exec -T postgres psql -U stablerail -d stablerail -c "
INSERT INTO provider_resources
    (id, tenant_id, provider, resource_type, provider_reference, metadata, created_at, updated_at)
VALUES
    ('acct_local', 'tenant-1', 'mock', 'account', 'mock_wallet', '{\"kind\":\"wallet\"}', now(), now()),
    ('instrument_local', 'tenant-1', 'mock', 'payment_instrument', 'mock_bank', '{\"kind\":\"bank_account\"}', now(), now())
ON CONFLICT (id) DO NOTHING;"

Then create and inspect a payment:

curl -X POST http://localhost:8080/v1/payments \
  -H "Authorization: Bearer $STABLERAIL_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: request-123' \
  -d '{"direction":"payout","external_reference":"order-123","currency":"USD","amount_minor":2500,"funding_method":"bank","source_account_id":"acct_local","destination_instrument_id":"instrument_local"}'

curl -H "Authorization: Bearer $STABLERAIL_API_KEY" \
  http://localhost:8080/v1/payments/PAYMENT_ID/timeline

Payment processing is asynchronous. Poll the payment or timeline endpoint to observe status changes. Reusing an idempotency key with the same request returns the original payment; reusing it with different fields returns 409 Conflict.

Reset the local database

The following reset permanently deletes the local PostgreSQL volume and all of its data. It also stops and removes the other containers in this Compose project. Do not run it against an environment containing data you need to preserve.

Remove the containers and named volumes:

docker compose down -v

Recreate PostgreSQL and wait until it accepts connections:

docker compose up -d postgres
docker compose exec -T postgres pg_isready -U stablerail -d stablerail

Apply all migrations to the empty database:

STABLERAIL_DATABASE_URL='postgresql://stablerail:stablerail@localhost:5432/stablerail?sslmode=disable' \
go run ./cmd/migrate

Verify that the migration history is present and the payment table is empty:

docker compose exec -T postgres psql -U stablerail -d stablerail \
  -c "SELECT count(*) AS applied_migrations FROM schema_migrations; SELECT count(*) AS payments FROM payments;"

Start the remaining services when needed:

docker compose up -d

To stop the environment without deleting its data, run docker compose down without -v.

API

Endpoint Purpose
POST /v1/payments Create a pay-in or payout payment (direction: "payin" or "payout")
GET /v1/payments/{id} Read a payment
GET /v1/payments/{id}/timeline Read payment history
POST /v1/payments/{id}/refunds Create a merchant-issued refund as a linked payment
POST /v1/payment-quotes Create a provider-neutral pay-in or payout quote
POST /v1/providers/blindpay/webhooks Receive signed BlindPay events
POST /v1/webhook-endpoints Register a tenant webhook
GET /v1/webhook-endpoints List tenant webhooks
DELETE /v1/webhook-endpoints/{id} Disable a tenant webhook
POST /v1/operator/tenants/{id}/api-keys Issue a tenant API key
DELETE /v1/operator/api-keys/{id} Revoke a tenant API key
POST /v1/operator/payments/{id}/manual-review Resolve a held payment
POST /v1/operator/mock-settlements/{id} Resolve a local mock settlement when enabled
GET /healthz Liveness check
GET /readyz PostgreSQL readiness check
GET /metrics Prometheus metrics

Tenant endpoints require Authorization: Bearer <api-key>. Operator endpoints are available only when STABLERAIL_OPERATOR_TOKEN is set and require that token. Payment reads are tenant-scoped. direction is required and must be payin or payout.

POST /v1/payments returns 202 Accepted for both directions. Provider instructions and later lifecycle statuses are asynchronous. Direct creation may create a provider quote before the payment is persisted; provider execution itself remains asynchronous.

Settlement providers

The application selects one settlement.SettlementProvider, which composes direction-specific QuoteProvider and ExecutionProvider capabilities. Quote requests carry provider-neutral route IDs. Both execution capabilities use the shared paymentcore.ExecuteRequest, containing only an idempotency key and provider quote ID. The runtime includes a deterministic mock provider and a BlindPay adapter with durable payout submission, pay-ins, signed webhooks, compliance holds, reconciliation, and coordinator-driven ambiguous-outcome retries.

BlindPay refund semantics depend on direction and timing. A payout or pay-in refunded before success becomes payment_status=failed, while its direction record and journals retain settlement and accounting detail. After payout success, returned funds are recorded as a separate payment_returns operation and reversal journal while the original payment remains succeeded. None of these provider-originated events is a merchant-issued refund.

Configure the BlindPay provider with:

export STABLERAIL_BLINDPAY_API_KEY='...'
export STABLERAIL_BLINDPAY_INSTANCE_ID='in_...'
export STABLERAIL_BLINDPAY_WEBHOOK_SECRET='whsec_...'
export STABLERAIL_BLINDPAY_NETWORK='base'
export STABLERAIL_BLINDPAY_TOKEN='USDC'
export STABLERAIL_BLINDPAY_MANAGED_WALLET_ID='bl_...'
export STABLERAIL_BLINDPAY_MANAGED_WALLET_ADDRESS='0x...'

Register the public HTTPS URL https://your-host/v1/providers/blindpay/webhooks in the BlindPay dashboard. This is an inbound provider endpoint, not a client API. StableRail verifies its Svix signature headers before storing or processing a delivery.

StableRail persists a payout submission attempt before calling BlindPay. If the response is lost, the saga coordinator re-enqueues execution with the payment's original provider idempotency key; reconciliation can independently confirm the result. A verified webhook or reconciliation result, rather than the initial HTTP response alone, determines the final payment and settlement statuses.

See BlindPay lifecycle testing for provider scenarios and expected accounting behavior.

Deployment

The initial production architecture runs the current single-process application as a replicated monolith on AWS ECS Fargate, backed by RDS PostgreSQL and Amazon MSK. ADR 0001 records why this is the current decision, the measured triggers for moving to role-based deployments, and the stricter requirements for selectively extracting microservices.

The AWS Terraform deployment provisions the network, load balancer and DNS, ECS service, ECR repository, Multi-AZ database, TLS Kafka cluster, secrets access, logging, autoscaling, and one-off migration task.

Testing

Run the unit and package tests:

go test ./...

Run race detection and static analysis:

go test -race ./...
go vet ./...

Run the end-to-end lifecycle suites:

# Provider-free lifecycle tests using the mock settlement provider
./scripts/test-e2e-local.sh

# BlindPay adapter lifecycle tests using the local BlindPay mock server
./scripts/test-e2e-blindpay.sh

Pass normal go test arguments to select a scenario:

./scripts/test-e2e-local.sh -run '^TestLOCAL001SuccessfulPaymentLifecycle$'

Each script starts an isolated PostgreSQL and Kafka stack, applies migrations, builds StableRail, runs its suite, and removes the stack afterward. Set STABLERAIL_E2E_KEEP_STACK=1 to retain the containers for inspection. See the local lifecycle test guide and BlindPay lifecycle test guide for the executable scenario specifications.

Operational status

The core payment, saga, managed-wallet payout, pay-in, merchant-issued refund, provider-return, webhook, reconciliation, and manual-review paths are implemented. Merchant refunds are linked payments and reuse the ordinary payout saga; provider-originated returns remain separate operations with reversal accounting. Remaining production work includes distributed tracing, alert integrations, credential rotation, provider rate limiting, rollout controls, and external-wallet payout submission.

The mock provider and local Compose environment are intended for development and verification. A production deployment still requires environment-specific security controls, topic provisioning, monitoring, and a limited provider pilot.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages