Skip to content

Saving Circles

Saving Circles is the implementation of Arisan rotational savings — a group of users contributes each round, and one member receives the payout per round. The module provides circle CRUD, member management, contributions (gateway or balance rail), and admin summaries. POST /saving-circles accepts two DTO shapes: CreateSavingCircleDto (regular user) or AdminCreateSavingCircleDto (admin creating on behalf of employees).

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, payment-gateway (bisabiller), users, organizations, withdrawal
Document versionv1 · 2026-05-20
AudienceInternal FE devs (mobile + web)

Typical flow: create circleadd members (member accounts) → each round members contribute (gateway VA / QR or balance debit) → the round winner receives the payout. Organization admins can view statistics summaries for circles in their org. The visibility field controls whether the circle is discoverable in other orgs’ marketplaces or kept private.

MethodPathSummary
POST/v1/saving-circlesCreate a saving circle (regular or admin oneOf DTO)
POST/v1/saving-circles/membersAdd members to a circle
POST/v1/saving-circles/contributeContribute to a circle (gateway / balance)
GET/v1/saving-circlesList circles with status / org / user filters
GET/v1/saving-circles/:idDetail of a single circle
PATCH/v1/saving-circles/:idUpdate circle parameters
DELETE/v1/saving-circles/:idDelete a circle (not allowed when there are active contributions)
GET/v1/summary/saving-circlesStatistics summary (admin MORIA/ORGANIZATION)

POST /v1/saving-circles bearer

Section titled “POST /v1/saving-circles ”

Create a new saving circle. The server picks the DTO based on user_type: INDIVIDUAL uses CreateSavingCircleDto, MORIA/ORGANIZATION uses AdminCreateSavingCircleDto (creating a circle for employees).

bearer create-saving-circle RESOURCE_CREATED

Request body — CreateSavingCircleDto (regular user)

Section titled “Request body — CreateSavingCircleDto (regular user)”
FieldTypeRequiredNotes
account_idstring (UUID)Source account of the circle owner
namestringCircle name (e.g. Hajj)
capacitynumberoptionalMaximum number of members, min 1
start_datedate (ISO 8601)Start date of the first round
amountstringContribution nominal per round (rupiah)

Request body — AdminCreateSavingCircleDto (MORIA / ORGANIZATION)

Section titled “Request body — AdminCreateSavingCircleDto (MORIA / ORGANIZATION)”
FieldTypeRequiredNotes
account_idstring (UUID)Sponsoring organization account
namestringCircle name
capacitynumberoptionalMaximum number of members
start_datedate (ISO 8601)Start date
amountstringContribution nominal per round
{
"account_id": "be2cb7da-e217-401c-8d07-b01e64adfb34",
"name": "Hajj",
"capacity": 10,
"start_date": "2028-02-06",
"amount": "240000000"
}
{
"status": "success",
"statusCode": 201,
"message": "Saving circle created successfully",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Hajj",
"amount": "240000000.0000",
"capacity": 10,
"current_turn": 0,
"start_date": "2028-02-06",
"status": "new",
"visibility": "private",
"owner_type": "individual",
"owner_id": "770e8400-e29b-41d4-a716-446655440222",
"account_id": "be2cb7da-e217-401c-8d07-b01e64adfb34",
"created_at": "2026-05-20T08:30:00.000Z"
}
}
StatusWhen it occurs
400 Bad RequestValidation failed (capacity < 1, invalid date)
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenMissing create-saving-circle permission

POST /v1/saving-circles/members bearer

Section titled “POST /v1/saving-circles/members ”

Add new members to an existing saving circle. Only the creator or an admin may add. Send a list of account_ids for prospective members.

bearer create-saving-circle RESOURCE_UPDATED
FieldTypeRequiredNotes
saving_circle_idstring (UUID)Target circle
account_idsstring[] (UUID)optionalList of prospective member accounts
{
"saving_circle_id": "550e8400-e29b-41d4-a716-446655440000",
"account_ids": [
"03be5259-f281-478e-a8d0-e7e825e525f2",
"03be5259-f281-478e-a8d0-e7e825e525f3"
]
}
{
"status": "success",
"statusCode": 201,
"message": "Members added successfully",
"data": { "members": [ { "id": "...", "saving_circle_id": "...", "account_id": "...", "turn_number": null } ] }
}
StatusWhen it occurs
400 Bad RequestValidation failed · circle is full · account is already a member
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the circle creator/admin
404 Not FoundCircle not found

POST /v1/saving-circles/contribute bearer

Section titled “POST /v1/saving-circles/contribute ”

A member contributes to the circle for the current round. Can use the balance rail (debit Moria balance directly) or the gateway rail (Bisabiller). Dedup rule: a row with status PENDING or COMPLETED for the same (circle_id, user_id, turn_number) rejects a repeat attempt; FAILED/CANCELLED rows allow retry.

bearer update-saving-circle CONTRIBUTION_MADE

Request body — ContributeToSavingCircleDto

Section titled “Request body — ContributeToSavingCircleDto”
FieldTypeRequiredNotes
saving_circle_idstring (UUID)Target circle
contribution_amountnumeric stringMust equal the circle’s per-round amount · IsNotZero + IsValidDecimal
{
"saving_circle_id": "550e8400-e29b-41d4-a716-446655440000",
"contribution_amount": "1000.00"
}
{
"status": "success",
"statusCode": 200,
"message": "Contribution made successfully",
"data": {
"contribution": {
"id": "880e8400-e29b-41d4-a716-446655440333",
"saving_circle_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "770e8400-e29b-41d4-a716-446655440222",
"amount": "1000.0000",
"turn_number": 3,
"status": "completed",
"rail": "balance"
}
}
}
StatusWhen it occurs
400 Bad RequestAmount does not match circle.amount · user already has a PENDING/COMPLETED row for this turn · insufficient balance
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot a circle member
404 Not FoundCircle not found
  • Inserts a row into saving_circle_contributions (status=PENDING for gateway, COMPLETED for balance).
  • For balance rail: ledger transaction debits the user account and credits the holding circle account.
  • Emits BusinessEvent CONTRIBUTION_MADE (impact MEDIUM).

List saving circles. Scope depends on role: INDIVIDUAL sees their own circles, ORGANIZATION admin sees the org’s circles, MORIA sees all. Supports organization_id for marketplace lookup (searching for public circles in another org).

bearer read-saving-circle
ParamTypeDefaultNotes
pagenumber1Page number
limitnumber10Records per page
statusenum SavingCircleStatusoptionalnew, active, cancelled, completed
order'asc' | 'desc'descSort by created_at
organization_idstring (UUID)optionalMarketplace lookup; INDIVIDUAL may only use their own organization
user_idstring (UUID)optionalUser filter; same authorization as saving-goals
account_idstring (UUID)optionalAccount filter; same authorization as saving-goals
{
"status": "success",
"statusCode": 200,
"message": "Saving circles retrieved successfully",
"data": {
"limit": 10,
"count": 5,
"currentPage": 1,
"totalPages": 1,
"saving_circles": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Hajj",
"amount": "240000000.0000",
"capacity": 10,
"current_turn": 2,
"status": "active",
"visibility": "private",
"owner_type": "individual",
"owner_id": "770e8400-e29b-41d4-a716-446655440222"
}
]
}
}
StatusWhen it occurs
400 Bad RequestInvalid query param
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenINDIVIDUAL queries another user’s data · ORGANIZATION queries another org

GET /v1/saving-circles/:id bearer

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

Detail of a single saving circle, including members and a contributions summary. Response format differs between WEB and MOBILE.

bearer read-saving-circle
ParamTypeNotes
idUUIDSaving circle ID
{
"status": "success",
"statusCode": 200,
"message": "saving circle fetched successfully",
"data": {
"savingCircle": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Hajj",
"amount": "240000000.0000",
"capacity": 10,
"current_turn": 2,
"status": "active",
"visibility": "private",
"owner_type": "individual",
"members": [
{ "id": "...", "account_id": "...", "user_id": "...", "turn_number": 1 }
]
}
}
}
StatusWhen it occurs
400 Bad Requestid is not a UUID
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNo access to the circle
404 Not FoundCircle not found

PATCH /v1/saving-circles/:id bearer

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

Update circle parameters. Only the creator or an admin may perform this. All fields are optional.

bearer update-saving-circle RESOURCE_UPDATED
ParamTypeNotes
idUUIDSaving circle ID
FieldTypeNotes
namestringNew name
capacitynumberNew capacity
start_datestringNew start date (free-form string; ISO format recommended)
amountstringPer-round contribution nominal
current_turnnumberCurrent turn
visibilityenum Visibilityprivate, public, organization
{ "capacity": 12, "visibility": "organization" }
{
"status": "success",
"statusCode": 200,
"message": "Saving circle updated successfully",
"data": { "...": "SavingCircleDto shape" }
}
StatusWhen it occurs
400 Bad RequestValidation failed
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the creator/admin
404 Not FoundCircle not found

DELETE /v1/saving-circles/:id bearer

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

Delete a saving circle. Not allowed if the circle is already ACTIVE with recorded contributions — use status cancellation elsewhere.

bearer delete-saving-circle RESOURCE_DELETED
ParamTypeNotes
idUUIDSaving circle ID
{
"status": "success",
"statusCode": 200,
"message": "Saving circle deleted successfully"
}
StatusWhen it occurs
400 Bad RequestCircle is ACTIVE with recorded contributions
401 UnauthorizedBearer/cookie token invalid
403 ForbiddenNot the creator/admin
404 Not FoundCircle not found

GET /v1/summary/saving-circles bearer

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

Organization-level saving circles statistics summary. Admin endpoint — only MORIA and ORGANIZATION. Includes totals, status breakdown, capacity utilization, etc.

bearer MORIA, ORGANIZATION read-saving-circle RESOURCE_FETCHED
{
"status": "success",
"statusCode": 200,
"message": "saving circles summary fetched successfully",
"data": {
"organization_id": "660e8400-e29b-41d4-a716-446655440111",
"organization_name": "Moria Fund",
"total_circles": "8",
"active_circles": "5",
"completed_circles": "2",
"cancelled_circles": "1",
"total_contribution_amount": "120000000.00",
"average_capacity": "8.50",
"average_current_turn": "2.40"
}
}
StatusWhen it occurs
401 UnauthorizedToken invalid · querying another org (mismatch)
403 ForbiddenRole is not MORIA/ORGANIZATION
404 Not FoundOrganization not found

  • new — circle created, not yet running
  • active — rounds in progress
  • cancelled — cancelled
  • completed — all rounds finished
  • private — only owner + members can see it
  • public — discoverable in the cross-org marketplace
  • organization — limited to organization members
  • pending · completed · failed · cancelled · refunded
  • gateway — VA/QR via Bisabiller
  • balance — direct debit of Moria balance
{
"message": "capacity must be at least 1",
"statusCode": 400,
"error": "Bad Request"
}
  • 400 validation · circle full · insufficient balance · duplicate contribution
  • 401 token expired / missing
  • 403 not owner/member · wrong scope
  • 404 circle not found
  • 500 internal — show a generic toast