Running the payments platform locally
This guide stands up the institutional / VALR-business payments platform on your machine and takes an ordinary account all the way to a working business entity with the two payment flows configured, funded, and callable from polygraph.
It targets valr-in-a-box as the primary
path (Docker supporting services, one command), with a manual ./gradlew run fallback for each service so
you can debug in IntelliJ. Existing IntelliJ run configs are called out where they exist — nothing here
replaces them.
This is a local development setup. It uses fake MFA/wallet services and a Pub/Sub emulator. It is not a
security reference — see payments-docs/security/findings-register.md for that.
Architecture recap
The money-path spans several services (full picture: payments-docs/architecture/topology.md):
| Service | Repo | Local port(s) | Role |
|---|---|---|---|
| projectx | ~/projects/projectx | 7080 (public), 7081 (admin), 7082 (ws), 7083 (v1), 7084 (v2), 7089 (v2-web), 8081 (admin config) | Exchange core, auth proxy/JWT ingress, RBAC, approvals, payout execution, address-book |
| payments-backend | ~/projects/payments-backend | 6090 | Orchestrator; entity/subaccount config; bulk-withdrawal; proxies reads to AES |
| account-events-service (AES) | ~/projects/account-events-service | 9119 | The automation engine (event → action); webhooks |
| payment-integration (mock) | ~/projects/payment-integration | 5679 | Fake conversion / bank integration for local money-path |
| payments-admin (SPA) | ~/projects/payments-admin (frontend) | 8000/8005 | Admin UI: flow setup, subaccount provisioning, wallet whitelist |
| payments-admin-server (BFF) | ~/projects/payments-admin (server) | 3010 | Admin BFF; fans out to backends |
| polygraph | ~/projects/polygraph | 3009 (server), UI via vite | API testing tool used to call the RBAC/entity endpoints |
Supporting services from valr-in-a-box: postgres (postgresql-server, :5432), auth-gateway (:9000/:9001),
fake-wallet (:16789), fake MFA (:8089), rabbitmq (:5672/:15672), pubsub-emulator (:8685).
Prerequisites
- Docker + Docker Compose
- JDK 21, Node 18+ / pnpm,
gcloudCLI (for the Pub/Sub emulator client) - Repos cloned under
~/projects(valr-in-a-box's./valr.shclones most of them)
Step 1 — Base stack (projectx + exchange)
Assumed working. This guide does not re-document the exchange/projectx build.
Primary: valr-in-a-box
cd ~/projects/valr-in-a-box
./valr.sh # clones supporting repos, then `docker compose up -d`
This starts postgres, auth-gateway, fake-wallet, fake-MFA, rabbitmq, and the Pub/Sub emulator, and
auto-creates the exchange databases (accounts, exchange-cluster, bittrex, live_recon, auth_gateway,
admin, monitor, matching_engine) via create-tables.sql.
Then run projectx + exchange (from their repos / your usual IntelliJ configs).
valr-in-a-box names the Postgres service postgresql-server (no fixed container_name), so the real
container is something like valr-in-a-box-postgresql-server-1. The payments helper scripts default to a
container named x-postgresql. Override per invocation:
PG_CONTAINER=valr-in-a-box-postgresql-server-1 ./scripts/create-db.sh
Or add container_name: postgresql-server to the compose service and use PG_CONTAINER=postgresql-server.
Either way the DB is reachable from host apps at localhost:5432 (user x-superuser).
Fallback: manual
Run your own Postgres (user x-superuser) and start projectx + exchange from IntelliJ / ./gradlew run.
Step 2 — Enable modifiable config flags
Payments/RBAC/approvals are gated by runtime-modifiable config (table modifiable_config_properties in the
accounts DB), editable via the admin API on :8081 (POST /admin/config/application/property, body
{"envName": "<KEY>", "value": "true"}) or directly in SQL. Writes are live (DB and in-memory cache) — no
restart needed.
Values below are verified against dev's config (2026-09-08) unless noted:
| Key | Value | Notes |
|---|---|---|
ENABLE_RBAC_SYSTEM | true | RBAC |
ENABLE_APPROVALS_SYSTEM | true | Approvals (maker-checker) engine |
ENABLE_AUDIT_SYSTEM | true | Audit engine |
ENABLE_RBAC_ACCOUNT_SPLIT | true | Enables the account→entity split admin endpoint |
PAYMENTS_WEB_ENABLED | true | Entity payment web routes. New + local-only — not in dev yet. |
PAYMENTS_RBAC_ENABLED | true | dev=false; only read by the V2 key-based /v2/payments proxy, harmless to enable locally |
PAYMENTS_BACKEND_PROXY_ENABLED | true | Proxy to payments-backend |
PLATFORM_EVENTS_PUBLISHING_ENABLED | true | Publish platform events (Pub/Sub emulator) |
PLATFORM_EVENT_AUDIT_LISTENER_ENABLED | true | Ingest AUDIT platform events |
BYPASS_ENTITY_LOOKUP_VALIDATION_ON_ADMIN_ENDPOINT | true | Admin entity endpoints resolve without full lookup validation (dev has this on) |
USE_ASYNC_BACKGROUND_EXECUTOR_CLASSES | true | defaults false. Routes debit/credit-response handling to the async commands. Required for entity payments — only the async handler raises the PAYMENT_FIAT/PAYMENT_CRYPTO approval; off ⇒ the withdrawal reaches PAYMENT_PENDING but no approval is ever created and the payment silently stalls. |
AUTOMATION_ENABLED_TEMPLATE_IDS | 82,99,52,37,54,151,25,58,30 | Automation template allowlist (dev value). Empty ⇒ PlatformEventQueueHandler drops every event, so no Flows fire. |
APPROVAL_DUAL_AUTH_ENTITY_ALLOWLIST | your entity id | Dual-auth allowlist — set after the split (Step 6a) with the numeric entityId. |
The seed-harness shortcut enableRbacAndApprovals sets ENABLE_RBAC_SYSTEM, ENABLE_APPROVALS_SYSTEM, and
ENABLE_RBAC_ACCOUNT_SPLIT; set the rest with setModifiableConfig:
cd ~/projects/projectx/api/scripts/node-scripts/seed-scripts
npm install
npm run command enableRbacAndApprovals
npm run command setModifiableConfig PAYMENTS_WEB_ENABLED true
npm run command setModifiableConfig USE_ASYNC_BACKGROUND_EXECUTOR_CLASSES true
npm run command setModifiableConfig AUTOMATION_ENABLED_TEMPLATE_IDS 82,99,52,37,54,151,25,58,30
# ...and the remaining flags from the table
The one-shot script sets all of these for you (and the dual-auth allowlist post-split).
There is no committed dev→local sync — pull manually. Port-forward the dev primary application admin API:
kubectl -n application-ns port-forward svc/primary-application-svc 8081:8081
then read it with the harness pointed at that port. Gotcha: getModifiableConfig prints via console.log,
which truncates arrays past ~100 items — capture the returned value, not the printed output:
EXCHANGE_ADMIN_BASE_URL=http://localhost:8081 node -e '
require("./index.js").getModifiableConfig().then(a =>
require("fs").writeFileSync("/tmp/dev-config.json", JSON.stringify(a, null, 2)))'
Step 3 — Seed admin users
The projectx Node seed harness (~/projects/projectx/api/scripts/node-scripts/seed-scripts) is the source
of truth. Admin identities are exchangedevtest1 / exchangedevtest2 (dual-auth needs both: user 1
initiates, user 2 approves). Customer test users are developers+testuser1@valr.com / testuser2.
cd ~/projects/projectx/api/scripts/node-scripts/seed-scripts
npm install # once
npm run createUsers # register + activate + api-key the test users
valr-in-a-box provides auth-gateway (:9000) for exchange customer/admin auth, not a local Keycloak. The
payments-admin BFF and service-to-service calls use the internal Keycloak realm in dev
(internal-auth.dev.rooibos.valr.world); client exports live in payments-docs/keycloak/dev/. Confirm which
your local stack points at before wiring payments-admin / polygraph auth.
Step 4 — Create the two accounts
You need two accounts:
- Liquidity account — a regular account holding balances / order-book liquidity on the exchange.
- Entity candidate — the account you will split into a business entity in Step 6.
npm run createUsers creates the two test users. Seed liquidity + order book:
npm run seedExchange -- override
Step 5 — Run payments-backend, AES, and the mock
Both services use Postgres via dotenv (localhost:5432, x-superuser). Neither DB is auto-created by
valr-in-a-box, so create them first (set PG_CONTAINER as in Step 1 if needed).
payments-backend (:6090)
cd ~/projects/payments-backend
./scripts/create-db.sh # CREATE DATABASE payments (PG_CONTAINER-aware)
./gradlew migrate # Flyway via the init entrypoint (IntelliJ: "Migrate Payments Backend")
./gradlew run # run task sets ENV_PATH; IntelliJ: "Run Payments Backend"
AUTH_BYPASS_LOCAL defaults to falseSet AUTH_BYPASS_LOCAL=true in src/main/environment/local/.env before running — it's read from the dotenv
file (not a shell var) and grants management:account:editor locally, which the admin entity/subaccount-config
calls (and the one-shot script) need. Without it those calls return 401.
account-events-service (:9119)
Migrations do not run on startup — run them explicitly (this guide added a Gradle migrate task mirroring
payments-backend; the IntelliJ Migrate run config already existed):
cd ~/projects/account-events-service
./scripts/setup-local.sh # CREATE DATABASE account_events (PG_CONTAINER-aware)
./gradlew :app:migrate # IntelliJ: "Migrate"
ENV_PATH=./src/main/environment/local ./gradlew run # IntelliJ: "Account Events Service"
AES needs Pub/Sub. With valr-in-a-box's emulator, point it at the emulator instead of real GCP:
export PUBSUB_EMULATOR_HOST=localhost:8685
The emulator provisions only platform-events + the platform-events-AUDIT subscription. The full money-path
uses more topics (account-platform-events, fiat-payout-requests, conversion-requests, …). Create the
extra topics/subs against the emulator for end-to-end flow execution, or run against real dev Pub/Sub with
per-developer subscriptions.
payment-integration mock (:5679)
For the money-path without real conversion/banking:
cd ~/projects/payment-integration
./gradlew :mock:run
Step 6 — Entity, payments config, subaccounts, wallet, flows
6a. Convert + split the account into an entity
The candidate account must be PRIMARY, activated, kyc_level ≥ 3, and CORPORATE. The harness handles the
preconditions and calls POST /admin/rbac/account-split:
cd ~/projects/projectx/api/scripts/node-scripts/seed-scripts
npm run command convertToCorporate user1
npm run command splitAccount user1
The split returns entityId, entityPrimaryAccountId, newUserAccountId, rbacUserId, grantId. Note the
numeric entityId — it goes in the dual-auth allowlist (APPROVAL_DUAL_AUTH_ENTITY_ALLOWLIST, Step 2). For
request headers you use the entity's UUID identifier instead (see Step 7).
6b. Enable payments for the entity
Upsert the entity config. An active row is what marks the account as a payments-portal customer, and
isLegacy: false switches it to RBAC identity resolution (needs AUTH_BYPASS_LOCAL=true, Step 5):
curl -s -X PUT "http://localhost:6090/admin/entities/<entityPrimaryAccountId>/config" \
-H "Content-Type: application/json" \
-H "X-Account-Id: <entityPrimaryAccountId>" \
-d '{"products":["COLLECTIONS","DISBURSEMENTS"],"businessHomeUrl":"http://localhost:3003/business","personalEnabled":true,"isLegacy":false}'
6c. Provision hidden subaccounts (payments-admin)
Run the admin UI + BFF:
cd ~/projects/payments-admin/server && ./gradlew run # BFF :3010
cd ~/projects/payments-admin/frontend && pnpm dev # SPA :8000
Local auth bypass: set LOCAL_DEV_EMAIL and LOCAL_DEV_ROLES (e.g. ACCOUNT_EDITOR,AUTOMATIONS_EDITOR) in
server/app/src/main/environment/local/.env to skip Keycloak and unlock the provisioning buttons.
In the Account Access page, the Subaccount Designations matrix is currencies [ZAR, USDC, USDT] ×
purposes [FUNDING, COLLECTIONS, PAYOUT]. Provisioning one cell issues:
POST /api/v1/admin/entities/{accountId}/subaccount-config/provision { currency, type }
which the BFF maps to the exchange admin POST /admin/account/subaccount/provision { accountId, label, tag }
(tags: COLLECTIONS, FUNDING, PAYOUTS, HOLD, PLATFORM_FEES — the segregation tags create hidden
subaccounts). Provision every cell your flows need:
- Collect + Settle (
COLLECTION_SETTLEMENT):COLLECTIONS/ZARin, convert to stablecoin, payout → needsCOLLECTIONS/ZAR+PAYOUT/USDC(see note). - Fund + Disburse (
FUNDING_DISBURSEMENT): funding in, convert to ZAR, bulk payout → needsFUNDING/ZAR+PAYOUT/ZAR.
Dev's standard entity uses four cells with USDT as the settlement leg: COLLECTIONS/ZAR, FUNDING/ZAR,
PAYOUT/ZAR, PAYOUT/USDT. Locally the script settles in USDC instead (PAYOUT/USDC) — the fake wallet
publishes a USDC address on the Ethereum network, so the settlement wallet address is guaranteed valid.
Either stablecoin works; one must be configured. Minimal single-flow entities are smaller (the Planet Express
dev entity has just PAYOUT/ZAR); the full currency matrix incl. HOLD/ZAR exists only on the kitchen-sink
PC Payments test entity.
6d. Wallet address (Collect + Settle only)
The wallet is set on the PAYOUT / settlements subaccount, not the ZAR funding subaccount — and only the
Collect + Settle flow has a wallet (Fund + Disburse pays out in ZAR). In payments-admin the wallet is the
"Wallet" field on the Settle leg, which calls POST /api/v1/admin/address-book (dual-auth approved) for the
PAYOUT subaccount; approved wallets list via GET /api/v1/admin/address-book?accountId={id}.
Locally, crypto addresses are served by the fake-wallet service (:16789), which has no blockchain and
accepts any send (broadcasts confirmations regardless). Use one of its own addresses so it also passes
projectx's address validation — e.g. USDC on the Ethereum network with n2oS45MeWkvK59od7dH6xFvJUTu19HgH62
(what addWallet / the skill use by default; override via SETTLEMENT_WALLET_ADDRESS).
6e. Configure the two flows
Create each flow in payments-admin (Create Flow modal → COLLECTION_SETTLEMENT / FUNDING_DISBURSEMENT),
which writes via POST /api/v1/admin/flows (dual-auth). Creating a flow auto-enables automation for the
entity's account — it writes the automation_enabled_accounts row for you; you don't set that manually. The
global AUTOMATION_ENABLED_TEMPLATE_IDS allowlist (Step 2) must still include the flow's template ids.
Step 7 — polygraph (entity / RBAC)
Run polygraph and point it at your local projectx:
cd ~/projects/polygraph
cp .env.example .env # set POLYGRAPH_DB, POLYGRAPH_PORT=3009
mkdir -p data
./gradlew run # server :3009
cd ui && npm run dev # dashboard
Use the LOCAL env; upstream ports are configured via app_settings keys
apimode.upstream_port.local.<target>.<kind> (defaults already match projectx: 7080/7081/7082/7083/7084/7089).
The RBAC backend selects the acting entity via the header X-VALR-Entity-Id, whose value is the entity's
UUID identifier (not the numeric entityId).
polygraph has first-class support: in Settings → API → <env> → Web users, edit a web proxy user and
tick "RBAC business user". Click Fetch to load the entities that user can act in (via the backend's
GET /rbac/entities) and pick one, or paste the entity UUID directly. From then on polygraph injects
X-VALR-Entity-Id automatically on that user's requests — in both the live proxy (Requests builder) and the
Action-runner/replay paths. It's fully optional: a user that isn't marked RBAC (and admin/api-key identities)
never gets the header, so ordinary traffic is unchanged.
Step 8 — Fund the primary account
Funding is a projectx dual-auth admin action (FUND_ACCOUNT): user 1 initiates, user 2 approves.
cd ~/projects/projectx/api/scripts/node-scripts/seed-scripts
npm run fundUser <entityPrimaryAccountId> ZAR 50000
npm run fundUser <entityPrimaryAccountId> USDC 10000
npm run fundUser <entityPrimaryAccountId> USDT 10000
One-shot script
For a resilient, re-runnable setup (checks state, does only what's missing, self-heals, and handles the
interactive second-user invite), use the local-entity-setup Claude Code skill
(payments-backend/.claude/skills/local-entity-setup/). The script below is the plain fresh-run path.
Steps 2–8 are automated by payments-backend/scripts/setup-local-entity.sh. It creates the service DBs
then drives the projectx seed-harness command setupLocalEntity, which runs: feature flags → test users +
liquidity → convert-to-corporate → entity split → enable entity payments config → provision + designate the
hidden sub-accounts for both flows → settlement wallet (non-fatal) → fund the primary (50000 ZAR / 10000 USDC /
10000 USDT). It prints the entityId and entityIdentifier at the end.
The flag step sets the full Step 2 set (including AUTOMATION_ENABLED_TEMPLATE_IDS pinned to the dev value and
PAYMENTS_WEB_ENABLED), and sets APPROVAL_DUAL_AUTH_ENTITY_ALLOWLIST to the new entity after the split.
# Prereqs: postgres + projectx/exchange up; payments-backend running with AUTH_BYPASS_LOCAL=true
# (grants account-editor locally, so no admin token is needed for the payments-backend calls).
cd ~/projects/payments-backend
PG_CONTAINER=postgresql-server ./scripts/setup-local-entity.sh # valr-in-a-box container name
Overrides: PG_CONTAINER, PG_USER, SEED_DIR, AES_DIR, PAYMENTS_BACKEND_BASE_URL,
SETTLEMENT_WALLET_ADDRESS, AUTOMATION_ENABLED_TEMPLATE_IDS. Prefer this for a fresh environment; use the
manual steps above to debug a single stage. Flow creation still happens in payments-admin (Step 6e).
Troubleshooting
docker exec x-postgresqlfails — you're on valr-in-a-box; setPG_CONTAINER(Step 1).- AES starts but no automations fire — check
PUBSUB_EMULATOR_HOST, the emulator topics, thatAUTOMATION_ENABLED_TEMPLATE_IDS(Step 2) is set, and that you've created a Flow (which writes theautomation_enabled_accountsrow). - payments-admin buttons greyed out — set
LOCAL_DEV_ROLESto includeACCOUNT_EDITOR/AUTOMATIONS_EDITOR. - RBAC endpoints 401/403 — the request needs a valid web identity (auth-gateway/Keycloak) and the
X-VALR-Entity-Idheader for entity-scoped routes.