Skip to content

Saving Goals

The Saving Goals module enables creation of savings targets (Hajj, emergency fund, etc.) with periodic auto-deduction from a source account into a dedicated holding account per goal. Endpoints are split across two controllers: SavingGoalsController at /saving-goals for CRUD + manual deduction, and OrgSavingGoalController for organization-level statistics summaries. Each goal has an owner_type (INDIVIDUAL/ORGANIZATION/MORIA), a lifecycle status, and a liquidity_type flag (loose vs locked).

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 modulesaccounts, users, organizations, investments (convert-goals), withdrawal
Document versionv1 · 2026-05-20
AudienceInternal FE devs (mobile + web)

FE creates a saving goal via POST /saving-goals — the service automatically creates a separate holding account (Architecture B). Users can pause, update parameters, run a manual deduction when auto-deduction fails, and delete the goal. Organization/MORIA admins can read aggregate summaries via GET /summary/saving-goals.

MethodPathSummary
POST/v1/saving-goalsCreate a new saving goal (auto-creates a holding account)
GET/v1/saving-goalsPaginated list of saving goals with status/liquidity/type filters
GET/v1/saving-goals/:idDetail of a single saving goal
PATCH/v1/saving-goals/:idUpdate goal parameters (name, target, deduction, etc.)
PATCH/v1/saving-goals/:id/pausePause/unpause auto-deduction
PATCH/v1/saving-goals/:id/manual-deductionManual deduction (recovery when auto fails)
DELETE/v1/saving-goals/:idDelete a saving goal
GET/v1/summary/saving-goalsOrganization statistics summary (admin / MORIA)

Create a new saving goal for the currently logged-in user. The service automatically creates a dedicated holding account per goal (Architecture B). For admins (MORIA/ORGANIZATION) the goal is created on behalf of the organization; for INDIVIDUAL the goal is owned by the user themselves.

bearer create-saving-goal SAVING_GOAL_CREATED
FieldTypeRequiredNotes
account_idstring (UUID)optionalSource account (if one already exists); empty → service creates a new holding account
namestringGoal name (e.g. Hajj)
liquidity_typeenum LiquidityTypeoptionalloose (default) or locked
target_amountstringTarget nominal as a numeric string (rupiah, integer)
start_datedate (ISO 8601)Accumulation start date (YYYY-MM-DD)
end_datedate (ISO 8601)Target completion date (YYYY-MM-DD)
deduction_amountstringPeriodic auto-deduction nominal (rupiah)
deduction_datestringDay of month (1..28) when auto-deduction runs
owner_typeenum OwnerTypeoptionalOnly applies to MORIA superadmin; ignored for INDIVIDUAL/ORGANIZATION (inferred from user_type)
{
"name": "Hajj",
"liquidity_type": "loose",
"target_amount": "2400",
"start_date": "2026-02-06",
"end_date": "2027-02-06",
"deduction_amount": "200",
"deduction_date": "25"
}
{
"status": "success",
"statusCode": 201,
"message": "Saving goal created successfully",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Hajj",
"slug_name": "hajj",
"target_amount": "2400.0000",
"saved_amount": "0.0000",
"deduction_amount": "200.0000",
"deduction_date": "2026-02-25",
"start_date": "2026-02-06",
"end_date": "2027-02-06",
"duration": 12,
"status": "active",
"liquidity_type": "loose",
"owner_type": "individual",
"owner_id": "770e8400-e29b-41d4-a716-446655440222",
"account_id": "880e8400-e29b-41d4-a716-446655440333",
"created_at": "2026-05-20T08:30:00.000Z"
}
}
StatusWhen it occurs
400 Bad RequestValidation failed (invalid date, non-numeric amount, unknown field)
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenMissing create-saving-goal permission
  • Creates a new row in saving_goals + a holding accounts row (atomic, transactional).
  • Emits BusinessEvent SAVING_GOAL_CREATED (impact MEDIUM).
  • Starts the auto-deduction scheduler based on deduction_date.

List the saving goals belonging to the logged-in user or organization. Supports layered filters and pagination. Authorization: INDIVIDUAL is limited to own data, ORGANIZATION admin to their organization, MORIA is unrestricted.

bearer read-saving-goal RESOURCE_FETCHED
ParamTypeDefaultNotes
pagenumber1Page number
limitnumber10Records per page
statusenum SavingGoalStatusoptionalnew, active, paused, cancelled, completed, converted
liquidity_typeenum LiquidityTypeoptionalloose or locked
order'asc' | 'desc'descSort by created_at
typeenum SavingGoalFetchTypeoptionalindividual, organization, all, due_saving_goals, moria. For INDIVIDUAL always forced to individual
user_idstring (UUID)optionalFilter by user; INDIVIDUAL may only use their own user.id
account_idstring (UUID)optionalFilter by account; INDIVIDUAL may only use their own account
{
"status": "success",
"statusCode": 200,
"message": "Saving goals retrieved successfully",
"data": {
"limit": 10,
"count": 24,
"currentPage": 1,
"totalPages": 3,
"saving_goals": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Hajj",
"slug_name": "hajj",
"target_amount": "2400.0000",
"saved_amount": "850.0000",
"deduction_amount": "200.0000",
"deduction_date": "2026-02-25",
"status": "active",
"liquidity_type": "loose",
"owner_type": "individual",
"owner_id": "770e8400-e29b-41d4-a716-446655440222",
"account_id": "880e8400-e29b-41d4-a716-446655440333",
"duration": 12,
"created_at": "2026-05-20T08:30:00.000Z"
}
]
}
}
StatusWhen it occurs
400 Bad RequestInvalid query param
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenINDIVIDUAL sends another user’s user_id/account_id · ORGANIZATION admin sends a user from another org

GET /v1/saving-goals/:id bearer

Section titled “GET /v1/saving-goals/:id ”

Detail of a single saving goal. Authorization is checked in layers: INDIVIDUAL must be the owner, ORGANIZATION admin must be from the same org, MORIA is unrestricted. The response format differs between WEB (richer) and MOBILE (raw entity).

bearer read-saving-goal RESOURCE_FETCHED
ParamTypeNotes
idUUIDSaving goal ID — validated via ParseUUIDPipe
{
"status": "success",
"statusCode": 200,
"message": "saving goal fetched successfully",
"data": {
"savingGoal": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Hajj",
"slug_name": "hajj",
"target_amount": "2400.0000",
"saved_amount": "850.0000",
"deduction_amount": "200.0000",
"deduction_date": "2026-02-25",
"start_date": "2026-02-06",
"end_date": "2027-02-06",
"duration": 12,
"deduction_failed_attempts": 0,
"deduction_next_retry_date": null,
"status": "active",
"liquidity_type": "loose",
"owner_type": "individual",
"owner_id": "770e8400-e29b-41d4-a716-446655440222",
"account_id": "880e8400-e29b-41d4-a716-446655440333"
}
}
}
StatusWhen it occurs
400 Bad Requestid is not a UUID
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the owner / not from the same org / not MORIA for a MORIA goal
404 Not FoundGoal not found

PATCH /v1/saving-goals/:id bearer

Section titled “PATCH /v1/saving-goals/:id ”

Update saving goal parameters (name, target, deduction, status). All fields are optional — send only what changed. A completed status from the client is reset to active by the server.

bearer update-saving-goal SAVING_GOAL_UPDATED
ParamTypeNotes
idUUIDSaving goal ID
FieldTypeNotes
namestringNew name
target_amountstringNew target nominal
start_datedate (ISO 8601)New start date
end_datedate (ISO 8601)New end date
deduction_amountstringNew auto-deduction nominal
deduction_datenumeric stringDay of month for auto-deduction
statusenum SavingGoalStatusnew, active, paused, cancelled, completed, converted (server forces completedactive)
{
"name": "Hajj 2027",
"target_amount": "3000",
"deduction_amount": "250"
}
{
"status": "success",
"statusCode": 200,
"message": "Saving goal updated successfully",
"data": { "...": "same shape as SavingGoalDto" }
}
StatusWhen it occurs
400 Bad RequestValidation failed
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the goal owner
404 Not FoundGoal not found

PATCH /v1/saving-goals/:id/pause bearer

Section titled “PATCH /v1/saving-goals/:id/pause ”

Toggle pause/unpause for auto-deduction. While paused, the periodic scheduler does not deduct funds until it is resumed.

bearer pause-saving-goal SAVING_GOAL_UPDATED
ParamTypeNotes
idUUIDSaving goal ID
FieldTypeRequiredNotes
pausebooleantrue → pause, false → resume
{ "pause": true }
{
"status": "success",
"statusCode": 200,
"message": "Saving goal paused successfully",
"data": { "...": "SavingGoalDto shape, status changes to 'paused' or 'active'" }
}
StatusWhen it occurs
400 Bad Requestpause is not a boolean
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the goal owner
404 Not FoundGoal not found

PATCH /v1/saving-goals/:id/manual-deduction bearer

Section titled “PATCH /v1/saving-goals/:id/manual-deduction ”

Run a manual deduction when auto-deduction fails or the user wants to top up outside the schedule. A goal already completed rejects the request and returns 200 with an informative message.

bearer update-saving-goal RESOURCE_UPDATED
ParamTypeNotes
idUUIDSaving goal ID
FieldTypeRequiredNotes
deduction_amountnumeric stringAmount deducted from the source account to the holding account; IsNotZero + IsValidDecimal
{ "deduction_amount": "500" }
{
"status": "success",
"statusCode": 200,
"message": "Manual deduction processed successfully",
"data": { "...": "SavingGoalDto shape, saved_amount increased" }
}

If the goal is already completed, response: { "statusCode": 200, "message": "Saving goal is already completed!" } — no deduction is performed.

StatusWhen it occurs
400 Bad Requestdeduction_amount is zero / non-decimal
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the goal owner / insufficient account balance
404 Not FoundGoal not found

DELETE /v1/saving-goals/:id bearer

Section titled “DELETE /v1/saving-goals/:id ”

Delete a saving goal. Permanent operation (soft-delete via deleted_at from AuditableEntity); the holding account balance is handled by the service.

bearer delete-saving-goal SAVING_GOAL_DELETED
ParamTypeNotes
idUUIDSaving goal ID
{
"status": "success",
"statusCode": 200,
"message": "Saving goal deleted successfully"
}
StatusWhen it occurs
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the goal owner
404 Not FoundGoal not found
  • Emits BusinessEvent SAVING_GOAL_DELETED (impact HIGH).
  • Sub-records still holding a balance in the holding account are moved/retained per service logic.

GET /v1/summary/saving-goals bearer

Section titled “GET /v1/summary/saving-goals ”

Aggregate saving goals summary for the organization of the logged-in user. Admin endpoint — only MORIA and ORGANIZATION are allowed. Useful for analytics dashboards (counts, totals, averages, progress).

bearer MORIA, ORGANIZATION read-saving-goal RESOURCE_FETCHED
{
"status": "success",
"statusCode": 200,
"message": "saving goals summary fetched successfully",
"data": {
"organization_id": "660e8400-e29b-41d4-a716-446655440111",
"organization_name": "Moria Fund",
"total_goals": "10",
"total_saved_amount": "25000000.00",
"total_target_amount": "100000000.00",
"total_deduction_amount": "5000000.00",
"new_goals": "2",
"active_goals": "5",
"paused_goals": "1",
"cancelled_goals": "0",
"completed_goals": "2",
"avg_duration_months": "12",
"avg_progress_percentage": "35.00",
"goals_reached_target": "2"
}
}
StatusWhen it occurs
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenRole is not MORIA/ORGANIZATION or missing permission

  • new — newly created, not yet running
  • active — auto-deduction running
  • paused — auto-deduction paused
  • cancelled — cancelled by user/admin
  • completed — target reached
  • converted — converted into an investment (see Investments module)
  • loose — funds can be withdrawn at any time (default)
  • locked — funds locked until end_date
  • individual, organization, all, moria, due_saving_goals
  • individual · organization · moria
{
"message": "deduction_amount must be a number string",
"statusCode": 400,
"error": "Bad Request"
}

message can be a string or an array of strings (multi-field validation errors).

  • 400 body/query/param validation
  • 401 token expired / missing
  • 403 not the owner / wrong scope / insufficient balance
  • 404 goal not found
  • 500 internal — show a generic toast