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. Ownership is always derived from the JWT (Architecture B): INDIVIDUAL → individual-owned goal for the caller; ORGANIZATION → organization-owned goal for the caller’s org; MORIA → may set owner_type=MORIA. Org admins may pass an optional user_id to create an individual-owned goal on behalf of a member of their own organization. The holding account is created automatically server-side.

bearer create-saving-goal SAVING_GOAL_CREATED
FieldTypeRequiredNotes
user_idstring (UUID)optionalNew. Only meaningful when the caller is an organization admin — create an INDIVIDUAL-owned goal on behalf of a member of the admin’s own organization. Ignored for individual callers (must match own user.id if sent — otherwise 403). When omitted, an org caller creates an ORGANIZATION-owned goal and an individual caller creates a goal for themselves.
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 OwnerTypeoptionalSet to MORIA (requires user_type=MORIA) to create a Moria-owned goal. Ignored for INDIVIDUAL/ORGANIZATION callers — owner_type is inferred from the JWT.
{
"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 — including legacy account_id/organization_id)
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenMissing create-saving-goal permission · individual caller sent a user_id that does not match their own · org admin sent a user_id for a user outside their organization
  • 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.

Scope depends on role:

  • INDIVIDUAL — own goals only (owner_id = caller), plus organization-owned goals in the caller’s own org they’re entitled to view.
  • ORGANIZATION admin — default (type=all) returns the combined set: org-owned goals (owner_type=ORGANIZATION, owner_id=admin's org) and goals owned by individual members of the same organization. type=organization returns only org-owned; type=individual returns only the org’s member-individual goals (scoped via users.organization_id — does not leak across organizations). When user_id is supplied (must belong to the admin’s org), it narrows to that one member’s goals.
  • MORIA — unrestricted across all owner types.

Web / mobile — honors x-client-type (default mobile). web → formatted list (formatSavingGoalList()); mobile → raw, with account and audit fields (created_by/updated_by/deleted_by) stripped.

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. For ORG admin: all (default) → combined org-owned + member-individual; organization → org-owned only; individual → member-individual only (org-scoped).
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": "all saving goals fetched successfully",
"data": {
"limit": 10,
"count": 146,
"currentPage": 1,
"totalPages": 15,
"savingGoals": [
{
"id": "c55b0bfb-d13d-4c38-811f-e60fb5807f93",
"created_at": "2026-05-26T07:40:49.755Z",
"updated_at": "2026-05-26T07:40:49.000Z",
"name": "Demo Hajj Cancelled",
"slug_name": "demo_hajj_cancelled",
"owner_type": "individual",
"owner_id": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"customization_id": "5b482830-f39f-460e-8931-0a63b96fd541",
"account_id": "c34d8dfd-79c7-4776-aec9-00ba476c0802",
"status": "cancelled",
"liquidity_type": "loose",
"saved_amount": "0.0000",
"target_amount": "2400000.0000",
"deduction_amount": "200000.0000",
"deduction_date": "2026-06-25",
"start_date": "2026-05-26",
"end_date": "2027-05-26",
"deduction_failed_attempts": 0,
"deduction_next_retry_date": null,
"duration": 12,
"customization": {
"id": "5b482830-f39f-460e-8931-0a63b96fd541",
"created_at": "2026-05-26T07:40:49.764Z",
"updated_at": "2026-05-26T07:40:49.764Z",
"created_by": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"updated_by": "00000000-0000-0000-0000-000000000000",
"deleted_by": "00000000-0000-0000-0000-000000000000",
"reference_id": "c55b0bfb-d13d-4c38-811f-e60fb5807f93",
"icon": "https://example.com/icon.png",
"theme": "light",
"color": "blue",
"reference_type": "saving_goal"
}
}
]
},
"lang": "en"
}
{
"status": "success",
"statusCode": 200,
"message": "all saving goals fetched successfully",
"data": {
"limit": 10,
"count": 146,
"currentPage": 1,
"totalPages": 15,
"savingGoals": [
{
"id": "c55b0bfb-d13d-4c38-811f-e60fb5807f93",
"created": {
"at": "2026-05-26T07:40:49.755Z",
"by": {}
},
"updated": {
"at": "2026-05-26T07:40:49.000Z",
"by": {}
},
"deleted": {
"at": null,
"by": {}
},
"name": "Demo Hajj Cancelled",
"status": "cancelled",
"liquidity_type": "loose",
"owner_type": "individual",
"owner_id": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"saved_amount": "0.0000",
"target_amount": "2400000.0000",
"deduction_amount": "200000.0000",
"deduction_date": "2026-06-25",
"start_date": "2026-05-26",
"end_date": "2027-05-26",
"duration": 12,
"account": {
"id": "c34d8dfd-79c7-4776-aec9-00ba476c0802",
"account_number": "SG-XDCPS4HAQY",
"name": "Demo Hajj Cancelled",
"account_type": "saving_goal"
},
"customization": {
"id": "5b482830-f39f-460e-8931-0a63b96fd541",
"color": "blue",
"theme": "light",
"icon": "https://example.com/icon.png"
}
}
]
},
"lang": "en"
}
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).

Web / mobile — honors x-client-type (default mobile). web → formatted detail incl. account, customization, and contributions; mobile → raw entity with account stripped.

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": "c55b0bfb-d13d-4c38-811f-e60fb5807f93",
"created_at": "2026-05-26T07:40:49.755Z",
"updated_at": "2026-05-26T07:40:49.000Z",
"created_by": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"updated_by": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"deleted_by": "00000000-0000-0000-0000-000000000000",
"name": "Demo Hajj Cancelled",
"slug_name": "demo_hajj_cancelled",
"owner_type": "individual",
"owner_id": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"customization_id": "5b482830-f39f-460e-8931-0a63b96fd541",
"account_id": "c34d8dfd-79c7-4776-aec9-00ba476c0802",
"status": "cancelled",
"liquidity_type": "loose",
"saved_amount": "0.0000",
"target_amount": "2400000.0000",
"deduction_amount": "200000.0000",
"deduction_date": "2026-06-25",
"start_date": "2026-05-26",
"end_date": "2027-05-26",
"deduction_failed_attempts": 0,
"deduction_next_retry_date": null,
"duration": 12,
"customization": {
"id": "5b482830-f39f-460e-8931-0a63b96fd541",
"created_at": "2026-05-26T07:40:49.764Z",
"updated_at": "2026-05-26T07:40:49.764Z",
"created_by": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"updated_by": "00000000-0000-0000-0000-000000000000",
"deleted_by": "00000000-0000-0000-0000-000000000000",
"reference_id": "c55b0bfb-d13d-4c38-811f-e60fb5807f93",
"icon": "https://example.com/icon.png",
"theme": "light",
"color": "blue",
"reference_type": "saving_goal"
}
}
},
"lang": "en"
}
{
"status": "success",
"statusCode": 200,
"message": "Saving goal fetched successfully",
"data": {
"savingGoal": {
"id": "c55b0bfb-d13d-4c38-811f-e60fb5807f93",
"created": {
"at": "2026-05-26T07:40:49.755Z",
"by": {}
},
"updated": {
"at": "2026-05-26T07:40:49.000Z",
"by": {}
},
"deleted": {
"at": null,
"by": {}
},
"name": "Demo Hajj Cancelled",
"status": "cancelled",
"liquidity_type": "loose",
"owner_type": "individual",
"owner_id": "5ebbf5f6-9e18-421c-8f1d-d6286adcd2b0",
"saved_amount": "0.0000",
"target_amount": "2400000.0000",
"deduction_amount": "200000.0000",
"deduction_date": "2026-06-25",
"start_date": "2026-05-26",
"end_date": "2027-05-26",
"duration": 12,
"account": {
"id": "c34d8dfd-79c7-4776-aec9-00ba476c0802",
"account_number": "SG-XDCPS4HAQY",
"name": "Demo Hajj Cancelled",
"account_type": "saving_goal"
},
"customization": {
"id": "5b482830-f39f-460e-8931-0a63b96fd541",
"color": "blue",
"theme": "light",
"icon": "https://example.com/icon.png"
},
"member_contributions": [
{
"user_id": "",
"total_contribution": "0.00",
"contribution_count": 0
}
]
}
},
"lang": "en"
}
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