Payments
The payments module is a generic dispatcher for every cross-feature payment transaction. Two main endpoints: POST /v1/payments/gateway (external rail via Amdigipay/Bisabiller — asynchronous, result via callback) and POST /v1/payments/balance (internal balance rail — synchronous, debit + domain update in a single DB transaction). The body’s reference_type field determines which handler is executed.
| Property | Value |
|---|---|
| Base URL | {HOST}/v1 |
| Auth | Bearer JWT (header Authorization) or cookie access_token |
| Content-Type | application/json |
| Error envelope | { "message": string | string[], "statusCode": number, "error": string } |
| Validation | Global ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400 |
| Related modules | payment-gateway, accounts, saving-goals, saving-circles, charitable-cause, commodity-financing |
| Document version | v1 · 2026-05-20 |
| Audience | Internal FE devs (mobile + web) |
Summary
Section titled “Summary”This generic endpoint replaces per-feature POSTs such as /accounts/:id/topup, /saving-goals/:id/topup, etc. Per-request flow: PaymentPermissionGuard resolves the handler based on reference_type, checks that the caller has handler.requiredPermission, then dispatches to buildGatewayPayload() or executeBalancePayment() depending on the rail. For gateway: the response contains the Amdigipay envelope (VA number / QR / payment link). For balance: the response contains the new_balance after debit.
| Method | Path | Auth | Summary |
|---|---|---|---|
| POST | /v1/payments/gateway | bearer | Initiate payment via Amdigipay (asynchronous) |
| POST | /v1/payments/balance | bearer | Debit Moria account balance + apply domain effect (synchronous) |
POST /v1/payments/gateway bearer
Section titled “POST /v1/payments/gateway ”Initiate an external payment via Amdigipay. The response contains the Amdigipay envelope (VA number, QR payload, or hosted-checkout URL). Domain effects (account topped-up, goal contribution recorded) happen asynchronously when the Amdigipay callback arrives.
bearer per handler (see cover note)Request body — GatewayPaymentDto
Section titled “Request body — GatewayPaymentDto”| Field | Type | Required | Notes |
|---|---|---|---|
reference_type | enum ReferenceType (payment-gateway) | yes | Selects the handler. Values: account_top_up, charitable_cause_donate, charitable_cause_open_donate, saving_goal_contribution, investment, saving_circle_contribution, commodity_financing_payment, trusted_partner_investment, takaful_insurance, takaful_tabaru_contribution |
reference_id | UUID | yes | UUID of the domain entity being paid for (account, saving goal, cause, etc.) |
payment_id | integer | yes | Amdigipay/Bisabiller channel id. Get it from GET /v1/payment-gateway/channels |
amount | number | yes | Amount in IDR. Positive. |
transaction_name | string | optional | Label on bank statement (max 100 chars). The handler fills in a default if empty. |
transaction_desc | string | optional | Long description (max 255 chars). |
Example request
Section titled “Example request”{ "reference_type": "saving_goal_contribution", "reference_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "payment_id": 23, "amount": 50000, "transaction_name": "Saving goal contribution", "transaction_desc": "Contribute to goal Umrah 2026"}Response — 201 Created
Section titled “Response — 201 Created”{ "status": "success", "statusCode": 201, "message": "Payment created", "data": { "payment_order_id": "fcd8dc3b-9f1b-46e8-91ca-402147556022", "transaction_id": "PAY-1ce9ab3d-9b1c-4d2f-9b6a-1d2c3a4f5e6b", "payment_id": 23, "payment_name": "VA MANDIRI", "nominal": 50000, "admin_fee": 4000, "transaction_total": 54000, "payment_code": "888981005813953", "payment_links": null, "qr_code": null, "expired_date": "2026-04-08 14:07:26", "status_id": 2, "status": "On processing" }}FE displays payment_code (VA number), qr_code (QRIS payload), or redirects to payment_links (e-wallet) depending on the chosen channel. Poll status via GET /v1/payment-gateway/orders/:payment_order_id. expired_date is in Bisabiller wire format (YYYY-MM-DD HH:mm:ss, not ISO) — do not pass directly to new Date().
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 Bad Request | Invalid body, reference_type unknown, method unsupported by handler, or domain validation failed (e.g. goal already completed) |
401 Unauthorized | Bearer/cookie invalid |
403 Forbidden | Caller missing handler.requiredPermission |
Side effects
Section titled “Side effects”- Creates a
PaymentOrderrow with initial statusPENDING→WAITING_PAYMENT. - Handlers with dedup (saving-circle, etc.) create a pending domain row before the call to Amdigipay.
- No balance / final domain change until the Amdigipay callback arrives.
POST /v1/payments/balance bearer
Section titled “POST /v1/payments/balance ”Debit a Moria account balance (caller-specified) then apply the domain effect in a single DB transaction. Synchronous: the response already reflects the final balance. Only handlers that declare 'balance' in supportedMethods accept this rail.
Request body — BalancePaymentDto
Section titled “Request body — BalancePaymentDto”| Field | Type | Required | Notes |
|---|---|---|---|
reference_type | enum ReferenceType | yes | Target handler. Must support the balance rail. |
reference_id | UUID | yes | UUID of the domain entity |
source_account_id | UUID | yes | UUID of the Moria account being debited |
amount | number | yes | Amount in IDR. Positive. Cannot exceed balance. |
Example request
Section titled “Example request”{ "reference_type": "saving_goal_contribution", "reference_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "source_account_id": "9e8a1d2c-4b5f-4c6d-8e7a-1b2c3d4e5f60", "amount": 50000}Response — 201 Created
Section titled “Response — 201 Created”{ "status": "success", "statusCode": 201, "message": "Balance debited and domain effect applied.", "data": { "transaction_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "50000", "new_balance": "450000.0000", "reference_type": "saving_goal_contribution", "reference_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" }}amount and new_balance are decimal strings (not numbers) — use a decimal library in FE for arithmetic. new_balance already reflects the post-debit balance — FE can display it directly without refetching.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 Bad Request | Invalid body, method unsupported by handler, insufficient balance, or domain validation failed |
401 Unauthorized | Bearer/cookie invalid |
403 Forbidden | Caller missing handler permission |
Side effects
Section titled “Side effects”- Debits
source_account_id(decrement balance). - Creates a
transactionsrow (type per handler). - Updates the domain entity (goal contribution, donation, etc.).
- All changes are bundled in one DB transaction — atomic.
Reference
Section titled “Reference”Enum: ReferenceType (payment-gateway)
Section titled “Enum: ReferenceType (payment-gateway)”account_top_upcharitable_cause_donatecharitable_cause_open_donatesaving_goal_contributioninvestmentsaving_circle_contributioncommodity_financing_paymenttrusted_partner_investmenttakaful_insurancetakaful_tabaru_contribution
This enum comes from src/payment-gateway/enums/reference-type.enum.ts. Different from the ReferenceType enum in src/common used by the transactions module.
Standard error envelope
Section titled “Standard error envelope”{ "message": "You are not a member of this saving circle", "statusCode": 403, "error": "Forbidden"}message may be a string or array of strings (multi-field validation).
Common HTTP codes
Section titled “Common HTTP codes”400body validation, handler reject (insufficient balance, unsupported method, etc.)401Bearer/cookie invalid403handler permission not satisfied500internal — generic toast
Integration notes
Section titled “Integration notes”- Gateway flow: wait for the Amdigipay callback for final confirmation. Polling
GET /v1/payment-gateway/orders/:idcan be used while waiting. - Balance flow: response is already final — no polling needed.
- Concurrent submissions from one user are rejected by handlers with dedup. The UI should disable the submit button after the first click.