Backend Development
The bopen backend is a single Rust microservice: the payment-service (services/payment-service).
Tech stack
- Rust with Cargo workspace
- Axum HTTP framework
- Tokio async runtime
- deadpool-postgres connection pooling
- rust_decimal for precise monetary arithmetic (no floating-point)
- Tracing for structured logging
Services
| Service | Crate | Default port | Purpose |
|---|---|---|---|
| Payment Service | payment-service | 8081 | Payment intent lifecycle, consent management, fee calculation |
API surface
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /health | None | Health check |
POST | /api/v1/payments | JWT | Create payment consent + intent; schedules payment.initiated webhook |
GET | /api/v1/payments/:id | JWT | Fetch payment status, fee breakdown, and audit trail |
POST | /api/v1/payments/:id/sca-complete | None | Complete SCA callback; advance state machine; schedules payment.authorized/settled/failed webhook |
POST | /api/v1/payments/:id/refund | JWT | Transition Settled → Refunded; schedules payment.refunded webhook |
POST | /api/v1/portal/register | None | Self-service acquirer registration (provisions Keycloak realm + merchant DB record) |
GET/POST | /api/v1/portal/users | JWT | List / invite realm staff (Keycloak email invite via SES) |
DELETE | /api/v1/portal/users/:id | JWT | Remove staff user from realm |
GET | /api/v1/portal/webhook-secret | JWT | Masked signing secret + verification snippet |
POST | /api/v1/portal/webhook-secret/rotate | JWT | Generate new signing secret |
GET | /api/v1/portal/webhooks | JWT | Last 50 webhook deliveries for the merchant |
POST | /api/v1/portal/webhooks/:id/replay | JWT | Re-queue a DEAD delivery |
POST /api/v1/payments — request headers
| Header | Required | Description |
|---|---|---|
X-Idempotency-Key | Yes | UUID; safe retries without duplicate charges |
X-Merchant-Client-Id | No | Cognito client ID; defaults to dev-acquirer-client-0001 |
Business logic at payment creation
- Resolve merchant by
X-Merchant-Client-Id; return 400 if unknown, inactive, orkyb_status != APPROVED - Check idempotency key; return existing payment if already created
- Calculate fee:
fee_amount = round(gross × fee_percentage + fee_flat, 4); return 400 iffee ≥ gross - Generate
end_to_end_id:BOP+ 32-char UUID hex (35 chars; BACS/FPS field limit) - Insert
payment_consentrow (AWAITING_AUTHORISATION, expires in 90 minutes) - Insert
payment_intentrow linked to the consent with fee breakdown snapshotted - Insert initial
payment_eventsrow (changed_by: MERCHANT_API) - Return
{payment_id, redirect_url, end_to_end_id, gross_amount, fee_amount, net_amount}
Environment variables
| Variable | Description | Default |
|---|---|---|
PORT | HTTP listen port | 8081 |
RUST_LOG | Log level filter | payment_service=info,tower_http=info |
DATABASE_URL | Full PostgreSQL connection string | — |
DB_HOST | Host (alternative to DATABASE_URL) | — |
DB_PORT | Port | 5432 |
DB_USERNAME | Database user | — |
DB_PASSWORD | Database password | — |
DB_NAME | Database name | bopen |
DB_SSL_MODE | Set to disable for local dev without TLS | — |
MOCK_BANK_BASE_URL | Base URL for mock bank redirect stubs | http://localhost:3000 |
RUN_MIGRATIONS | Set to true to apply migrations at startup | — |
KEYCLOAK_ADMIN_USER | Keycloak master realm admin username (from Secrets Manager) | — |
KEYCLOAK_ADMIN_PASSWORD | Keycloak master realm admin password | — |
SMTP_SECRET | JSON blob from SES Secrets Manager ({host,port,from,username,password}) | — |
Running locally
DATABASE_URL="postgres://bopen_app:<password>@localhost:5432/bopen" \
MOCK_BANK_BASE_URL="http://localhost:3000" \
DB_SSL_MODE="disable" \
cargo run -p payment-service
The service health check is available at http://localhost:8081/health.
Docker build
docker build \
-f services/payment-service/Dockerfile \
-t bopen/payment-service \
.
docker run \
-p 8081:8081 \
-e DATABASE_URL="postgres://..." \
-e MOCK_BANK_BASE_URL="http://localhost:3000" \
bopen/payment-service
Database migrations
Migration scripts live under database/scripts/migrations/ and are applied in numbered order. When RUN_MIGRATIONS=true is set, the payment-service applies them at startup in the order defined in migrate.rs.
| Migration | Tables / changes |
|---|---|
001_initial_schema.sql | customers, accounts, transactions |
002_permissions.sql | Schema grants |
003_b2b_payments.sql | merchants, payment_intents, payment_events; payment_status enum |
004_audit_enforcement.sql | Immutable ledger: triggers + REVOKE on payment_events |
005_merchant_enrichment.sql | KYB status, fee model, merchant_bank_accounts |
006_payment_consents.sql | Consent-vs-execution: payment_consents table; fee columns on payment_intents |
007_audit_ledger_enrichment.sql | changed_by, ordinal on payment_events |
009_rename_client_id.sql | Renames cognito_client_id → client_id on merchants |
010_schema_completions.sql | failure_code on intents; Refunded enum; raw_payload_sha256 on events; debtor_bank_id on consents |
011_payer_data_fields.sql | Payer context fields on payment_consents (AML / OBL) |
012_webhook_deliveries.sql | realm_slug, webhook_signing_secret on merchants; webhook_deliveries table |
See Database Schema for the full ER model, state machines, and script run order.
Adding a new service
- Create
services/<name>/withCargo.tomlandsrc/main.rs - Add the crate to the workspace
membersin the rootCargo.toml - Add a
Dockerfilefor ECS deployment - Register the service in the ECS CloudFormation template (
infra/cloudformation)