Skip to content

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.

PropertyValue
Base URL{HOST}/v1
AuthBearer JWT (header Authorization) or cookie access_token
Content-Typeapplication/json
Error envelope{ "message": string | string[], "statusCode": number, "error": string }
ValidationGlobal ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400
Related modulessaving-goals, charitable-cause, investments, accounts, saving-circles
Document versionv1 · 2026-05-20
AudienceInternal FE devs (mobile + web)

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.

MethodPathAuthSummary
POST/v1/withdrawalsbearerInitiate close-loop withdrawal (unified, multi-feature)

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.

bearer <dynamic per handler>

Request body — UnifiedWithdrawalRequestDto

Section titled “Request body — UnifiedWithdrawalRequestDto”
FieldTypeRequiredNotes
reference_typeenum ReferenceTypeyesSelects the handler. Members: donation, saving_goal, saving_circle, investment, commodity_financing, account_top_up. Note: not all support user-initiated calls.
reference_idUUIDyesUUID of the domain entity being withdrawn from (saving goal id, investment id, cause id, etc.)
amountnumeric stringoptionalWithdrawal amount (IDR). Required for “caller-provided” handlers (saving_goal, investment capital). Ignored by handlers that compute it themselves (saving_circle turn, investment ROI).
reasonstringoptionalFree-text reason, max 500 chars. Stored on the Withdrawals row + audit log.
{
"reference_type": "saving_goal",
"reference_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"amount": "500000",
"reason": "Emergency medical expense"
}
{
"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.

StatusWhen it occurs
400 Bad RequestBody malformed · reference_type unknown (Unknown reference_type 'X'. Valid: ...) · handler supportsUserInitiated=false (Withdrawals of type 'X' cannot be user-initiated) · domain validation failed
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenCaller missing handler.requiredPermission (Missing permission '...' for ...)
404 Not FoundDomain reference not found · user has no main account (seeder not yet run)
  • Creates a Withdrawals row (initial status PENDING, updated to APPROVED/FAILED at 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.

  • 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 withdrawal
  • commodity_financing — commodity financing holding withdrawal (admin only — see commodity-financing module)
  • account_top_up — related to top-up reversal
  • pending — transient, briefly set while dispatcher validates + executes
  • approved — terminal success; funds already moved to the main account
  • failed — terminal failure; reason contains the error
  • rejected — reserved (future admin-review flow)
  • cancelled — reserved (future user cancel)
  • user — end-user via POST /v1/withdrawals
  • system — internal sweeper/scheduler (via event, not HTTP)
  • saving_goalupdate-saving-goal · userInitiated YES
  • investmentwithdraw-investment · userInitiated YES
  • donationupdate-charitable-cause · userInitiated YES
  • account_top_uptop-up-account
  • saving_circleupdate-saving-circle · userInitiated NO (HTTP rejected)
{
"message": "Missing permission 'update-saving-goal' for saving_goal.",
"statusCode": 403,
"error": "Forbidden"
}
  • 400 body malformed · ref_type unknown · supportsUserInitiated=false
  • 401 token expired / missing
  • 403 permission insufficient (check handler.requiredPermission)
  • 404 domain reference missing · main account not seeded
  • 500 internal — show a generic toast