A production-ready Go modular monolith that aggregates multiple Indonesian payment providers behind a single API. Merchants integrate once; Wanpey routes to Midtrans, Xendit, DOKU, and iPaymu transparently.
- Overview
- Architecture
- Payment Flow
- Provider Capabilities
- API Reference
- Admin Permission Matrix
- Fee Resolution
- Infrastructure
- Getting Started
- Configuration
- Testing
- Security
- Database Migrations
Wanpey operates as a Payment Facilitator (PayFac):
- Holds one provider account each at Midtrans, Xendit, DOKU, and iPaymu
- All merchant cash-in flows into Wanpey's provider accounts
- Merchant balances tracked in an internal ledger (
mutationstable), not at provider level - Cash-out is disbursed from Wanpey's provider balance to merchant's verified bank account
- Switching or adding providers is invisible to merchants
Wanpey follows Clean Architecture. Dependencies flow inward — outer layers depend on inner layers, never the reverse.
graph TD
A[delivery / HTTP<br/>Echo handlers · middleware · router]
B[usecase<br/>payment · disbursement · merchant · mutation]
C[domain<br/>entity · repository interfaces · gateway interfaces]
D[infrastructure<br/>database/postgres · provider adapters · cache · worker · taskqueue]
E[pkg/<br/>importable by all layers · no internal imports]
A --> B
B --> C
D --> C
E -.->|shared utils| A
E -.->|shared utils| B
E -.->|shared utils| D
Key design decisions:
| Concern | Solution |
|---|---|
| Dependency injection | samber/do v2 — lazy singletons, lifecycle via Shutdownable |
| DB access | sqlc-generated queries + database.RunInTx for multi-step ops |
| Async jobs | asynq over Redis — task queue for treasury operations |
| Distributed tracing | OpenTelemetry → Jaeger |
| Structured logging | zap with PII masking via pkg/mask |
| Configuration | TOML file (CONFIG_PATH) + secret env vars override |
sequenceDiagram
participant M as Merchant
participant W as Wanpey API
participant P as Provider
participant C as Customer
M->>W: POST /v1/payments/va (or /qris)
W->>P: Create payment
P-->>W: VA number / QR string
W-->>M: payment object
C->>P: Customer pays
P->>W: Webhook callback
Note over W: Verify signature<br/>Update status → paid<br/>Write mutation<br/>Insert outbox (same tx)
W->>M: Merchant webhook (HMAC-SHA256 signed)
sequenceDiagram
participant M as Merchant
participant W as Wanpey API
participant P as Provider
participant B as Merchant Bank
M->>W: POST /v1/disbursements
Note over W: Check balance<br/>Reserve row (RunInTx)
W->>P: Payout API call
P->>W: Webhook callback
Note over W: Update status<br/>Debit mutation
W->>M: Merchant webhook
P->>B: Funds arrive
sequenceDiagram
participant UC as Usecase
participant DB as Database
participant OW as Outbox Worker
participant ME as Merchant Endpoint
UC->>DB: BEGIN tx
UC->>DB: UPDATE payments SET status = paid
UC->>DB: INSERT INTO outbox (status = pending)
UC->>DB: COMMIT
loop poll every 5s
OW->>DB: SELECT ... FOR UPDATE SKIP LOCKED
OW->>ME: POST webhook (X-Wanpey-Signature)
alt success
OW->>DB: UPDATE outbox SET status = delivered
else failure
OW->>DB: ScheduleRetry / MarkFailedFinal
end
end
stateDiagram-v2
[*] --> pending
pending --> paid : provider confirms payment
pending --> cancelling : cancel requested
pending --> expired : TTL exceeded
pending --> failed : provider error
cancelling --> cancelled : provider confirms cancel
cancelling --> pending : provider call fails (reverted)
paid --> [*]
cancelled --> [*]
expired --> [*]
failed --> [*]
note right of paid : Final state — immutable
| Provider | Virtual Account | QRIS | Disbursement |
|---|---|---|---|
| Midtrans | ✅ | ✅ | ❌ |
| Xendit | ✅ | ✅ | ✅ |
| DOKU | ✅ | ✅ | ✅ |
| iPaymu | ✅ | ✅ | ❌ |
Supported banks for VA: BCA · BNI · BRI · BSI · Mandiri · Permata · CIMB
Interactive docs available at http://localhost:8080/swagger/index.html when the server is running.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check — DB, cache, outbox backlog |
POST |
/v1/payments/va |
Create Virtual Account payment |
POST |
/v1/payments/qris |
Create QRIS payment |
GET |
/v1/payments |
List own payments |
GET |
/v1/payments/:id |
Get payment detail |
DELETE |
/v1/payments/:id |
Cancel payment |
POST |
/v1/disbursements |
Disburse funds to bank account |
GET |
/v1/disbursements |
List disbursements |
GET |
/v1/disbursements/:id |
Get disbursement detail |
GET |
/v1/mutations |
Ledger history |
GET |
/v1/mutations/balance |
Current balance |
GET |
/v1/mutations/:id |
Get mutation detail |
GET |
/v1/merchants/me |
Own merchant profile |
PATCH |
/v1/merchants/me |
Update profile |
POST |
/v1/merchants/me/api-key/regenerate |
Rotate API key |
GET |
/v1/merchants/me/bank-accounts |
List bank accounts |
POST |
/v1/merchants/me/bank-accounts |
Add bank account |
DELETE |
/v1/merchants/me/bank-accounts/:id |
Remove bank account |
PATCH |
/v1/merchants/me/bank-accounts/:id/primary |
Set primary account |
GET |
/v1/merchants/me/webhook-events |
Webhook delivery history |
| Method | Path | Description |
|---|---|---|
POST |
/webhooks/:provider/payment |
Receive payment callback |
POST |
/webhooks/:provider/disbursement |
Receive disbursement callback |
| Method | Path | Role |
|---|---|---|
POST |
/admin/login |
Public |
POST |
/admin/token/refresh |
Public |
GET |
/admin/me |
All |
PATCH |
/admin/me/password |
All |
POST |
/admin/merchants |
super_admin, ops |
GET |
/admin/merchants |
All |
PATCH |
/admin/merchants/:id/approve |
super_admin, ops |
PATCH |
/admin/merchants/:id/suspend |
super_admin, ops |
PATCH |
/admin/merchants/:id/fee |
super_admin, finance |
PATCH |
/admin/merchants/:id/cashout-limit |
super_admin, finance |
PATCH |
/admin/merchants/:id/bank-accounts/:aid/verify |
super_admin, ops |
GET |
/admin/payments |
All |
GET |
/admin/disbursements |
All |
GET |
/admin/mutations |
super_admin, finance |
GET/PATCH |
/admin/provider-balances |
super_admin, finance |
GET/PUT |
/admin/fees/default |
super_admin, finance |
GET/PUT |
/admin/fees/margin |
super_admin (update), finance (read) |
GET/POST/PUT |
/admin/fees/holidays |
super_admin, finance |
POST/GET |
/admin/admins |
super_admin |
graph LR
subgraph super_admin
SA1[Create / Delete merchant]
SA2[Approve / Suspend merchant]
SA3[Set merchant fee]
SA4[Set cashout limit]
SA5[Verify bank account]
SA6[Manage admins]
SA7[View payments & disbursements]
SA8[View / Update provider balances]
SA9[Manage fee defaults]
SA10[Update platform margin]
SA11[Manage holiday surcharges]
end
subgraph ops
O1[Create / Delete merchant]
O2[Approve / Suspend merchant]
O5[Verify bank account]
O7[View payments & disbursements]
end
subgraph finance
F3[Set merchant fee]
F4[Set cashout limit]
F7[View payments & disbursements]
F8[View / Update provider balances]
F9[Manage fee defaults]
F11[Manage holiday surcharges]
end
All fee calculations go through FeeResolver — never computed ad-hoc.
flowchart TD
A[Transaction created] --> B{Merchant has\nFeeConfig?}
B -- yes --> C[Use merchant contracted fee\nper-method: VA flat · QRIS % · Disbursement flat]
B -- no --> D[Use global default\nfee_defaults table]
C --> E{Platform margin\nenabled?}
D --> E
E -- yes --> F[Add platform_margin]
E -- no --> G[Skip margin]
F --> H{Today is\nholiday?}
G --> H
H -- yes --> I[Add fee_holidays surcharge]
H -- no --> J[Skip surcharge]
I --> K[Total Fee = base + margin + surcharge]
J --> K
K --> L[Net settlement = paid amount − total fee\nrecorded as Mutation.Amount]
Every fee change requires an admin reason field → written to fee_audit_logs (immutable, append-only).
graph TB
subgraph App[:8080]
HTTP[Echo HTTP Server]
OW[outbox_worker\npoll every 5s]
EW[expiry_worker\npoll expired payments]
AW[asynq workers\ntreasury tasks]
end
subgraph Data
PGB[PgBouncer :6432\ntransaction pool mode]
PG[(Postgres :5432)]
RD[(Redis :6379\nasynq broker · cache)]
end
subgraph Observability
JG[Jaeger :16686\nOTLP traces]
AM[Asynqmon :8081\ntask queue dashboard]
SW[Swagger UI :8080/swagger]
end
HTTP --> PGB
OW --> PGB
EW --> PGB
AW --> PGB
AW --> RD
HTTP --> RD
PGB --> PG
App -->|OTLP gRPC| JG
sequenceDiagram
participant S as OS Signal
participant A as App
participant H as Echo HTTP
participant W as Workers
participant L as Logger
participant I as Infra
S->>A: SIGTERM
A->>H: Stage 1 — echo.Shutdown(ctx)
Note over H: drain in-flight requests
A->>W: Stage 2 — cancel worker context
Note over W: workerWg.Wait() · 15s hard deadline
A->>L: Stage 3 — zap.Sync()
Note over L: flush audit logs — must not be lost
A->>I: Stage 4 — injector.Shutdown()
Note over I: Echo → Redis → Postgres → Tracer
- Go 1.25+
- Docker + Docker Compose
- Make
# 1. Install dev tools
make install-tools
make install-hooks
# 2. Copy and fill in credentials
cp .config.example.toml .config.toml
# edit .config.toml with your provider sandbox keys
# 3. Start infrastructure
make infra-up
# 4. Run database migrations
make migrate-up
# 5. Create first admin account
make seed-admin EMAIL=admin@example.com PASSWORD=secret ROLE=super_admin
# 6. (Optional) Seed sample dev data
make seed-dev
# 7. Start the server with hot reload
make devServer runs at http://localhost:8080.
make dev # hot reload via Air
make test # unit tests (no network)
make test-integration # integration tests (requires .config.toml with real creds)
make test-e2e # end-to-end tests (requires infra-up)
make lint # golangci-lint
make migrate-up # apply pending migrations
make sqlc # regenerate DB code after editing query/*.sqlmake seed-merchant EMAIL=merchant@example.com NAME="Test Store" WEBHOOK_URL=http://localhost:9090/hook
# prints the API key once — save itCopy .config.example.toml → .config.toml. Override path with CONFIG_PATH env var.
Important:
migrate_dsnmust point directly to Postgres (:5432), not PgBouncer. golang-migrate uses advisory locks which PgBouncer transaction mode does not support.
| Env var | Purpose |
|---|---|
CONFIG_PATH |
Path to TOML config file |
DOKU_PRIVATE_KEY_PEM |
DOKU RSA private key PEM — use this in production instead of config file |
| Mode | Format |
|---|---|
| Sandbox | wpay_test_<32 random chars> |
| Production | wpay_live_<32 random chars> |
Raw key shown once at creation/regeneration. Stored as SHA256 hash in DB.
# Unit tests — fast, no network, no credentials
make test
# Integration tests — hit real provider sandboxes
# Requires .config.toml with valid sandbox credentials
make test-integration
# End-to-end tests — full stack against local infra
# Requires: make infra-up + make migrate-up + .config.toml
make test-e2e
# Single package / single test function
go test -race -run TestFunctionName ./internal/path/to/package/...Integration and E2E tests call t.Skip() when credentials are empty — safe to commit without credentials.
- Merchant API:
X-API-Keyheader → SHA256 hash lookup → constant-time comparison - Admin API: short-lived JWT access token + refresh token
- Inbound (provider → Wanpey): signature verified per-provider (HMAC or static token)
- Outbound (Wanpey → merchant): signed with merchant's
webhook_signing_keyvia HMAC-SHA256 (X-Wanpey-Signatureheader) - IP allowlist: optional per-provider CIDR allowlist for
/webhooks/*routes
| Pattern | Detail |
|---|---|
| Idempotency | SetNX atomic claim, 24h TTL, key idempotency:{merchant_id}:{key} |
| Outbox | Payment update + outbox insert in same DB transaction — no lost webhooks |
| Disbursement reservation | Pending row inserted inside balance-check transaction — prevents double-spend |
| Two-step cancel | pending → cancelling → cancelled — prevents double-cancel races |
| Circuit breaker | Wraps every provider network call (gobreaker) |
| Bank account limit | SELECT COUNT(*) FOR UPDATE inside transaction — prevents concurrent bypass of 3-account max |
| PII masking | pkg/mask wraps all sensitive fields before passing to logger |
Rate limiting is handled at the infrastructure layer (Nginx, Traefik, or cloud load balancer) — not in the app. Webhook routes (/webhooks/*) must never be rate limited.
Migrations live in migrations/ — golang-migrate format (NNNNNN_name.up.sql / .down.sql).
make migrate-up # apply all pending
make migrate-down # rollback last
make migrate-status # show current versionRun from project root — the file://migrations source path is CWD-relative.