Skip to main content

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.

What this is not

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):

ServiceRepoLocal port(s)Role
projectx~/projects/projectx7080 (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-backend6090Orchestrator; entity/subaccount config; bulk-withdrawal; proxies reads to AES
account-events-service (AES)~/projects/account-events-service9119The automation engine (event → action); webhooks
payment-integration (mock)~/projects/payment-integration5679Fake conversion / bank integration for local money-path
payments-admin (SPA)~/projects/payments-admin (frontend)8000/8005Admin UI: flow setup, subaccount provisioning, wallet whitelist
payments-admin-server (BFF)~/projects/payments-admin (server)3010Admin BFF; fans out to backends
polygraph~/projects/polygraph3009 (server), UI via viteAPI 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, gcloud CLI (for the Pub/Sub emulator client)
  • Repos cloned under ~/projects (valr-in-a-box's ./valr.sh clones 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).

Postgres container name

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:

KeyValueNotes
ENABLE_RBAC_SYSTEMtrueRBAC
ENABLE_APPROVALS_SYSTEMtrueApprovals (maker-checker) engine
ENABLE_AUDIT_SYSTEMtrueAudit engine
ENABLE_RBAC_ACCOUNT_SPLITtrueEnables the account→entity split admin endpoint
PAYMENTS_WEB_ENABLEDtrueEntity payment web routes. New + local-only — not in dev yet.
PAYMENTS_RBAC_ENABLEDtruedev=false; only read by the V2 key-based /v2/payments proxy, harmless to enable locally
PAYMENTS_BACKEND_PROXY_ENABLEDtrueProxy to payments-backend
PLATFORM_EVENTS_PUBLISHING_ENABLEDtruePublish platform events (Pub/Sub emulator)
PLATFORM_EVENT_AUDIT_LISTENER_ENABLEDtrueIngest AUDIT platform events
BYPASS_ENTITY_LOOKUP_VALIDATION_ON_ADMIN_ENDPOINTtrueAdmin entity endpoints resolve without full lookup validation (dev has this on)
USE_ASYNC_BACKGROUND_EXECUTOR_CLASSEStruedefaults 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_IDS82,99,52,37,54,151,25,58,30Automation template allowlist (dev value). Empty ⇒ PlatformEventQueueHandler drops every event, so no Flows fire.
APPROVAL_DUAL_AUTH_ENTITY_ALLOWLISTyour entity idDual-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).

Pulling values from dev

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
Keycloak vs auth-gateway

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:

  1. Liquidity account — a regular account holding balances / order-book liquidity on the exchange.
  2. 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 false

Set 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
Emulator topics

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/ZAR in, convert to stablecoin, payout → needs COLLECTIONS/ZAR + PAYOUT/USDC (see note).
  • Fund + Disburse (FUNDING_DISBURSEMENT): funding in, convert to ZAR, bulk payout → needs FUNDING/ZAR + PAYOUT/ZAR.
Verified against dev (2026-09-08) + local settlement currency

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)​

Correction to the common mental model

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​

Prefer the skill for re-runs

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-postgresql fails — you're on valr-in-a-box; set PG_CONTAINER (Step 1).
  • AES starts but no automations fire — check PUBSUB_EMULATOR_HOST, the emulator topics, that AUTOMATION_ENABLED_TEMPLATE_IDS (Step 2) is set, and that you've created a Flow (which writes the automation_enabled_accounts row).
  • payments-admin buttons greyed out — set LOCAL_DEV_ROLES to include ACCOUNT_EDITOR / AUTOMATIONS_EDITOR.
  • RBAC endpoints 401/403 — the request needs a valid web identity (auth-gateway/Keycloak) and the X-VALR-Entity-Id header for entity-scoped routes.