Withdrawal
The Withdrawals module is a single generic endpoint for every close-loop withdrawal in Moria: a dispatcher routes the request to a feature handler based on reference_type in the body (mirroring PaymentsController — “one endpoint, every feature, forever”). The destination is always the caller’s main close-loop account. Execution is synchronous in v1 (no admin approval); the response carries the terminal Withdrawals row with status APPROVED (success) or FAILED (error). Saving-circle turn completion and investment ROI distribution do NOT go through this endpoint — they use the internal WITHDRAWAL_REQUESTED_EVENT path handled by WithdrawalListener.
| 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 | saving-goals, charitable-cause, investments, accounts, saving-circles |
| Document version | v1 · 2026-05-20 |
| Audience | Internal FE devs (mobile + web) |
Summary
Section titled “Summary”FE calls one endpoint: POST /v1/withdrawals with a body containing reference_type + reference_id. WithdrawalPermissionGuard resolves the handler from the registry, verifies handler.supportsUserInitiated === true, and checks the handler-specific permission (e.g. update-saving-goal for saving goal). The dispatcher then: validate → calculateAmount → create Withdrawals row (PENDING) → execute → flip to APPROVED/FAILED. The response returns the Withdrawals row.
| Method | Path | Auth | Summary |
|---|---|---|---|
| POST | /v1/withdrawals | bearer | Initiate close-loop withdrawal (unified, multi-feature) |
POST /v1/withdrawals bearer
Section titled “POST /v1/withdrawals ”Initiate a close-loop withdrawal for one of the features (saving goal, charitable cause, investment capital, etc.). The handler is chosen from reference_type; permission is checked dynamically based on handler.requiredPermission.
Request body — UnifiedWithdrawalRequestDto
Section titled “Request body — UnifiedWithdrawalRequestDto”| Field | Type | Required | Notes |
|---|---|---|---|
reference_type | enum ReferenceType | yes | Selects the handler. Members: donation, saving_goal, saving_circle, investment, commodity_financing, account_top_up. Note: not all support user-initiated calls. |
reference_id | UUID | yes | UUID of the domain entity being withdrawn from (saving goal id, investment id, cause id, etc.) |
amount | numeric string | optional | Withdrawal amount (IDR). Required for “caller-provided” handlers (saving_goal, investment capital). Ignored by handlers that compute it themselves (saving_circle turn, investment ROI). |
reason | string | optional | Free-text reason, max 500 chars. Stored on the Withdrawals row + audit log. |
Example request
Section titled “Example request”{ "reference_type": "saving_goal", "reference_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "amount": "500000", "reason": "Emergency medical expense"}Response — 201 Created (success)
Section titled “Response — 201 Created (success)”{ "status": "success", "statusCode": 201, "message": "Withdrawal executed successfully", "data": { "withdrawal": { "id": "550e8400-e29b-41d4-a716-446655440000", "withdrawal_amount": "500000.00", "status": "approved", "reason": "Emergency medical expense", "review": null, "reference_type": "saving_goal", "reference_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "organization_id": "660e8400-e29b-41d4-a716-446655440111", "user_id": "770e8400-e29b-41d4-a716-446655440222", "created_at": "2026-05-20T08:30:00.000Z", "updated_at": "2026-05-20T08:30:00.000Z" } }}Response — 201 Created (failed terminal)
Section titled “Response — 201 Created (failed terminal)”{ "status": "success", "statusCode": 201, "message": "Withdrawal submitted for admin approval", "data": { "withdrawal": { "id": "550e8400-e29b-41d4-a716-446655440000", "withdrawal_amount": "500000.00", "status": "failed", "reason": "Insufficient balance in source account", "reference_type": "saving_goal", "reference_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } }}Note: the HTTP response stays 201 even when the handler fails at execute — the terminal status is in data.withdrawal.status. FE must check that field, not the status code, to determine logical success/failure.
Errors
Section titled “Errors”| Status | When it occurs |
|---|---|
400 Bad Request | Body malformed · reference_type unknown (Unknown reference_type 'X'. Valid: ...) · handler supportsUserInitiated=false (Withdrawals of type 'X' cannot be user-initiated) · domain validation failed |
401 Unauthorized | Bearer/cookie token invalid |
403 Forbidden | Caller missing handler.requiredPermission (Missing permission '...' for ...) |
404 Not Found | Domain reference not found · user has no main account (seeder not yet run) |
Side effects
Section titled “Side effects”- Creates a
Withdrawalsrow (initial statusPENDING, updated toAPPROVED/FAILEDat the end). - Moves funds from the feature source to the user’s main close-loop account.
- Handler-specific: updates saving goal / investment / cause status per the handler logic.
Reference
Section titled “Reference”Enum: ReferenceType
Section titled “Enum: ReferenceType”donation— donation withdrawal (charitable cause)saving_goal— saving goal withdrawal (user-initiated)saving_circle— saving circle turn (system-only, HTTP rejected)investment— investment capital/ROI withdrawalcommodity_financing— commodity financing holding withdrawal (admin only — see commodity-financing module)account_top_up— related to top-up reversal
Enum: WithdrawalStatus
Section titled “Enum: WithdrawalStatus”pending— transient, briefly set while dispatcher validates + executesapproved— terminal success; funds already moved to the main accountfailed— terminal failure;reasoncontains the errorrejected— reserved (future admin-review flow)cancelled— reserved (future user cancel)
Enum: WithdrawalInitiator
Section titled “Enum: WithdrawalInitiator”user— end-user viaPOST /v1/withdrawalssystem— internal sweeper/scheduler (via event, not HTTP)
Dynamic permissions (handler examples)
Section titled “Dynamic permissions (handler examples)”saving_goal→update-saving-goal· userInitiated YESinvestment→withdraw-investment· userInitiated YESdonation→update-charitable-cause· userInitiated YESaccount_top_up→top-up-accountsaving_circle→update-saving-circle· userInitiated NO (HTTP rejected)
Standard error envelope
Section titled “Standard error envelope”{ "message": "Missing permission 'update-saving-goal' for saving_goal.", "statusCode": 403, "error": "Forbidden"}Common HTTP codes
Section titled “Common HTTP codes”400body malformed · ref_type unknown · supportsUserInitiated=false401token expired / missing403permission insufficient (checkhandler.requiredPermission)404domain reference missing · main account not seeded500internal — show a generic toast