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).
Property Value Base URL {HOST}/v1Auth Bearer JWT (header Authorization) or cookie access_token Content-Type application/jsonError envelope { "message": string | string[], "statusCode": number, "error": string }Validation Global ValidationPipe · whitelist: true, forbidNonWhitelisted: true · unknown field → 400 Related modules accounts, users, organizations, investments (convert-goals), withdrawal Document version v1 · 2026-05-20 Audience Internal 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.
Method Path Summary 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)
Auth + scope notes
Every endpoint requires Bearer JWT. @Permissions(...) is checked in addition to the role guard.
INDIVIDUAL may only access their own goals; user_id/account_id filters are forced to their own data.
ORGANIZATION admins may query any user in their organization; sending a user_id from another org → 403.
MORIA superadmin is unrestricted; can create goals with owner_type=moria.
The account_id field on the create-saving-goal DTO is effectively optional under Architecture B (holding account auto-created); send it only when you already have a dedicated account.
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
Field Type Required Notes account_idstring (UUID) optional Source account (if one already exists); empty → service creates a new holding account namestring ✓ Goal name (e.g. Hajj) liquidity_typeenum LiquidityType optional loose (default) or lockedtarget_amountstring ✓ Target 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_amountstring ✓ Periodic auto-deduction nominal (rupiah) deduction_datestring ✓ Day of month (1..28) when auto-deduction runs owner_typeenum OwnerType optional Only applies to MORIA superadmin; ignored for INDIVIDUAL/ORGANIZATION (inferred from user_type)
"liquidity_type" : " loose " ,
"start_date" : " 2026-02-06 " ,
"end_date" : " 2027-02-06 " ,
"deduction_amount" : " 200 " ,
"message" : " Saving goal created successfully " ,
"id" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"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 " ,
"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 "
Status When 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
Param Type Default Notes pagenumber 1Page number limitnumber 10Records per page statusenum SavingGoalStatus optional new, active, paused, cancelled, completed, convertedliquidity_typeenum LiquidityType optional loose or lockedorder'asc' | 'desc'descSort by created_at typeenum SavingGoalFetchType optional individual, organization, all, due_saving_goals, moria. For INDIVIDUAL always forced to individualuser_idstring (UUID) optional Filter by user; INDIVIDUAL may only use their own user.id account_idstring (UUID) optional Filter by account; INDIVIDUAL may only use their own account
"message" : " Saving goals retrieved successfully " ,
"id" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"target_amount" : " 2400.0000 " ,
"saved_amount" : " 850.0000 " ,
"deduction_amount" : " 200.0000 " ,
"deduction_date" : " 2026-02-25 " ,
"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 "
Status When 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
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
Param Type Notes idUUID Saving goal ID — validated via ParseUUIDPipe
"message" : " saving goal fetched successfully " ,
"id" : " 550e8400-e29b-41d4-a716-446655440000 " ,
"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 " ,
"deduction_failed_attempts" : 0 ,
"deduction_next_retry_date" : null ,
"liquidity_type" : " loose " ,
"owner_type" : " individual " ,
"owner_id" : " 770e8400-e29b-41d4-a716-446655440222 " ,
"account_id" : " 880e8400-e29b-41d4-a716-446655440333 "
Status When it occurs 400 Bad Requestid is not a UUID401 UnauthorizedBearer/cookie token invalid 403 ForbiddenNot the owner / not from the same org / not MORIA for a MORIA goal 404 Not FoundGoal not found
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
Param Type Notes idUUID Saving goal ID
Field Type Notes namestring New name target_amountstring New target nominal start_datedate (ISO 8601) New start date end_datedate (ISO 8601) New end date deduction_amountstring New auto-deduction nominal deduction_datenumeric string Day of month for auto-deduction statusenum SavingGoalStatus new, active, paused, cancelled, completed, converted (server forces completed → active)
"deduction_amount" : " 250 "
"message" : " Saving goal updated successfully " ,
"data" : { "..." : " same shape as SavingGoalDto " }
Status When it occurs 400 Bad RequestValidation failed 401 UnauthorizedBearer/cookie token invalid 403 ForbiddenNot the goal owner 404 Not FoundGoal not found
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
Param Type Notes idUUID Saving goal ID
Field Type Required Notes pauseboolean ✓ true → pause, false → resume
"message" : " Saving goal paused successfully " ,
"data" : { "..." : " SavingGoalDto shape, status changes to 'paused' or 'active' " }
Status When it occurs 400 Bad Requestpause is not a boolean401 UnauthorizedBearer/cookie token invalid 403 ForbiddenNot the goal owner 404 Not FoundGoal not found
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
Param Type Notes idUUID Saving goal ID
Field Type Required Notes deduction_amountnumeric string ✓ Amount deducted from the source account to the holding account; IsNotZero + IsValidDecimal
{ "deduction_amount" : " 500 " }
"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.
Status When it occurs 400 Bad Requestdeduction_amount is zero / non-decimal401 UnauthorizedBearer/cookie token invalid 403 ForbiddenNot the goal owner / insufficient account balance 404 Not FoundGoal not found
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
Param Type Notes idUUID Saving goal ID
"message" : " Saving goal deleted successfully "
Status When 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.
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
"message" : " saving goals summary fetched successfully " ,
"organization_id" : " 660e8400-e29b-41d4-a716-446655440111 " ,
"organization_name" : " Moria Fund " ,
"total_saved_amount" : " 25000000.00 " ,
"total_target_amount" : " 100000000.00 " ,
"total_deduction_amount" : " 5000000.00 " ,
"avg_duration_months" : " 12 " ,
"avg_progress_percentage" : " 35.00 " ,
"goals_reached_target" : " 2 "
Status When 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 " ,
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