Skip to content

Commodity Financing

The Commodity Financing API lets partner organizations extend installment-based financing to their members for physical goods — vehicles, household appliances, equipment. Borrowers submit a request, organization admins approve terms, the legal agreement is signed, and the borrower then repays the financing in fixed installments. This guide is aimed at backend engineers integrating the API: every endpoint, every state transition, every field your client will send and receive.

PropertyValue
AudiencePartner integrators · backend engineers
Base URL/v1 · JSON over HTTPS
AuthBearer token (JWT) · per-endpoint permissions
Lifecyclepending · under_review · legal_agreement · signed · active · completed · rejected · cancelled
CurrencyIDR · amounts as decimal-string
Integration~1–2 sprints for a typical mobile or web client

A commodity financing represents an agreement between a borrower and their partner organization to acquire a physical good and repay its cost in installments. The borrower submits a request with the product details, the organization admin reviews and sets the approved installment amount and period, the borrower signs the legal agreement, and the financing becomes active. From there, installments are paid through the platform’s unified payments endpoint until paid_amount reaches reselling_price.

What it isn’t. Commodity Financing does not supply the physical good itself — fulfilment is the organization’s responsibility. It does not compute interest or apply late fees server-side. It does not auto-debit the borrower’s Moria balance for installments — repayments are borrower-initiated through the unified /v1/payments surface. And the per-financing holding account is owned by the organization: only the organization admin can move funds out of it.


flowchart LR
    B([Borrower])
    A([Organization admin])

    subgraph WALLET[" Borrower wallet "]
        MAIN[("Main balance<br/>(primary cash)")]
    end

    subgraph FIN[" Per-financing holding "]
        HOLD[("Holding account<br/>(org-owned, one per financing)")]
    end

    subgraph ORG[" Organization accounts "]
        DEST[("Destination account<br/>(org-owned)")]
    end

    EXT[/"External payment<br/>(VA · QRIS · e-wallet)"/]

    EXT -->|"gateway rail<br/>(POST /v1/payments/gateway)"| HOLD
    MAIN -->|"balance rail<br/>(POST /v1/payments/balance)"| HOLD
    HOLD -->|"admin withdrawal<br/>(POST /v1/commodity-financings/:id/withdraw)"| DEST

    B -.->|submits financing| HOLD
    A -.->|approves · withdraws| HOLD

    classDef money fill:#FDF8E8,stroke:#3C2C96,stroke-width:2px,color:#1E1B4B;
    classDef edge fill:#FFF3D0,stroke:#F4BE27,stroke-width:1.5px,color:#8B5A00;
    classDef user fill:#E8E1FF,stroke:#3C2C96,stroke-width:1.5px,color:#1E1B4B;
    class MAIN,HOLD,DEST money;
    class EXT edge;
    class B,A user;

Each financing has its own holding account, owned by the organization, created automatically when the financing is created. Installments — whether paid from the borrower’s main balance or via an external gateway — credit that holding account. The organization admin sweeps funds out of the holding into a destination organization account using the dedicated admin withdrawal endpoint. The borrower has no claim on the holding account.


flowchart TB
    classDef state fill:#FDF8E8,stroke:#3C2C96,stroke-width:1.5px,color:#1E1B4B
    classDef terminal fill:#EEF7F1,stroke:#2E7D5B,stroke-width:1.5px,color:#1E1B4B
    classDef bad fill:#FFE0E2,stroke:#D4232C,stroke-width:1.5px,color:#1E1B4B
    classDef note fill:#FFF8E6,stroke:#B8860B,stroke-width:1px,color:#3A332C,font-size:11px

    Start([●]) --> pending
    pending[pending]:::state --> under_review[under_review]:::state
    pending --> legal_agreement[legal_agreement]:::state
    under_review --> legal_agreement
    legal_agreement --> signed[signed]:::state
    signed --> active[active]:::state
    active --> completed[completed]:::terminal

    pending --> rejected[rejected]:::bad
    under_review --> rejected

    InstallmentNote["Installments paid via<br/>POST /v1/payments/balance<br/>reference_type:<br/>commodity_financing_payment"]:::note
    active -.- InstallmentNote

    CancelNote["The borrower can cancel any<br/>pre-active state via DELETE /:id.<br/>The admin can also cancel an active<br/>financing when holding = 0."]:::note
    pending -.- CancelNote

MethodPathPurposePermission
POST/v1/commodity-financingsCreate a financing (borrower or admin)create-commodity-financing
GET/v1/commodity-financingsList financings (paginated, filterable)read-commodity-financing
GET/v1/commodity-financings/:financing_idFetch one financing by idread-commodity-financing
PATCH/v1/commodity-financings/:financing_idUpdate editable fields (product, status, etc.)update-commodity-financing
POST/v1/commodity-financings/:financing_id/signBorrower signs the legal agreementsign-commodity-financing
PATCH/v1/commodity-financings/:financing_id/approveAdmin approves or rejects · sets termsupdate-commodity-financing
POST/v1/commodity-financings/:financing_id/withdrawAdmin sweeps funds out of the holding accountupdate-commodity-financing
DELETE/v1/commodity-financings/:financing_idBorrower cancels (only before active)delete-commodity-financing
GET/v1/summary/commodity-financingsOrganization-wide stats (org admins · Moria only)read-commodity-financing

Installment repayments are not on this surface — they use the platform-wide payments endpoints with reference_type: "commodity_financing_payment". See the repayment section below.


POST /v1/commodity-financings serves two callers — a borrower applying for themselves, or an organization admin opening a financing on behalf of a member. The endpoint inspects the user type from the bearer token to decide which DTO shape to expect: an individual caller sends the borrower DTO; an organization admin sends the admin DTO, which carries richer fields (target borrower user_id, optional pre-set approved_installment_amount, legal_agreement_id, etc.).

Borrower path · individual caller

  • Caller’s bearer token belongs to an INDIVIDUAL user
  • Body follows the borrower DTO — no user_id field; the borrower is the caller
  • The resulting financing starts at pending
  • The borrower’s organization is inferred from the caller

Admin path · organization caller

  • Caller’s bearer token belongs to an ORGANIZATION or MORIA user
  • Body follows the admin DTO — must include the borrower’s user_id
  • The admin can pre-fill approved_installment_amount and legal_agreement_id up front
  • The borrower must belong to the admin’s organization (enforced server-side)

Request

POST /v1/commodity-financings
Authorization: Bearer <token>
Content-Type: application/json
{
"product_name": "Honda Beat 2026",
"product_price": "24000000",
"product_link": "https://store.example/honda-beat",
"product_image_document_id": "9f3b-...-1d22",
"requested_installment_amount": "1000000",
"requested_installment_period": 24,
"deduction_date": "2026-06-25"
}

Response · 201 Created

{
"data": {
"commodityFinance": {
"id": "a8c1-...-c9f0",
"user_id": "be2c-...-fb34",
"organization_id": "0123-...-4567",
"account_id": "1111-...-2222",
"product_name": "Honda Beat 2026",
"product_price": "24000000.0000",
"requested_installment_amount": "1000000.00",
"requested_installment_period": 24,
"status": "pending",
"legal_agreement_signed": false,
"paid_amount": "0.00",
"remaining_amount": "0.00",
"created_at": "2026-05-18T03:12:44Z"
}
}
}

The account_id in the response is the financing’s holding account, provisioned automatically at create time. It is owned by the borrower’s organization, not by the borrower. Store this id alongside the financing id — you will see it referenced in payment events later.


Request

POST /v1/commodity-financings
Authorization: Bearer <token>
Content-Type: application/json
{
"user_id": "be2c-...-fb34",
"signature_id": "7777-...-8888",
"product_name": "MacBook Pro 14\"",
"product_price": "35000000",
"product_link": "https://store.example/mbp-14",
"product_image_document_id": "9f3b-...-1d22",
"requested_installment_amount": "1500000",
"approved_installment_amount": "1500000",
"requested_installment_period": 24,
"legal_agreement_id": "5555-...-6666",
"deduction_date": "2026-06-25"
}

---
config:
  sequence:
    actorMargin: 240
    width: 180
    messageMargin: 45
    boxMargin: 16
    noteMargin: 14
---
sequenceDiagram
    autonumber
    actor B as Borrower
    participant API as Moria API
    participant MAIN as Borrower main balance
    participant HOLD as Financing holding account
    actor A as Organization admin

    Note over B,API: One installment cycle (balance rail)
    B->>API: POST /v1/payments/balance<br/>{ reference_type: "commodity_financing_payment",<br/>reference_id, amount, source_account_id }
    API->>API: validate ownership · state · amount<br/>(amount must equal one installment)

    alt sufficient balance · validation passes
        API->>MAIN: debit amount
        API->>HOLD: credit amount
        API->>API: bump paid_amount<br/>· recompute remaining_amount<br/>· flip status if paid off
        API-->>B: 201 { new_balance, transaction_id }
    else insufficient balance or wrong amount
        API-->>B: 400 { statusCode, message, error }
    end

    Note over A,HOLD: Admin sweeps the holding (any time)
    A->>API: POST /v1/commodity-financings/:id/withdraw<br/>{ amount, destination_account_id }
    API->>HOLD: debit amount
    API->>API: credit destination_account_id<br/>(org-owned)
    API-->>A: 200 { transaction_id,<br/>destination_account_id, amount }

Balance rail · synchronous

POST /v1/payments/balance
Authorization: Bearer <token>
{
"reference_type": "commodity_financing_payment",
"reference_id": "a8c1-...-c9f0",
"amount": "1000000",
"source_account_id": "be2c-...-fb34"
}

Debits the borrower’s Moria account and credits the financing’s holding account in a single DB transaction. Returns the new source balance and a transaction id. Use this when the borrower has funds on the platform.

Gateway rail · asynchronous

POST /v1/payments/gateway
Authorization: Bearer <token>
{
"reference_type": "commodity_financing_payment",
"reference_id": "a8c1-...-c9f0",
"amount": "1000000",
"payment_id": "client-generated-uuid",
"transaction_name": "Honda Beat instalment #3"
}

Returns a payment-gateway envelope (VA number / QR / payment link). The financing is credited asynchronously once the gateway confirms payment — poll the gateway order or wait for your webhook to know the final state.


Approve or reject

PATCH /v1/commodity-financings/:financing_id/approve
Authorization: Bearer <token>
{
"status": "legal_agreement",
"approved_installment_amount": "1500000",
"requested_installment_period": 24
}

The admin pushes the financing toward legal_agreement (approved) or rejected. The approved installment amount and period are written to the entity at this step.

Borrower signs

POST /v1/commodity-financings/:financing_id/sign
Authorization: Bearer <token>
{ "legal_agreement_signed": true }

The borrower confirms the legal agreement. Sign can only be called from legal_agreement or signed (re-sign is idempotent). The financing transitions to active on success.

Sequence in practice

  1. Borrower or admin creates the financing — status starts as pending
  2. Admin reviews · calls PATCH /:financing_id/approve with status legal_agreement and the approved terms
  3. Your client downloads or generates the agreement document and presents it to the borrower
  4. Borrower calls POST /:financing_id/sign · status becomes active
  5. Borrower starts paying installments via /v1/payments/balance or /v1/payments/gateway

Request

POST /v1/commodity-financings/:financing_id/withdraw
Authorization: Bearer <token>
{
"amount": "5000000",
"destination_account_id": "0123-...-4567"
}

Moves funds from the financing’s holding account to an organization-owned destination account. Atomic ledger transfer; no domain update on the financing row.


Request

GET /v1/commodity-financings?status=active
&user_id=be2c-...-fb34
&page=1&limit=20
Authorization: Bearer <token>

Returns paginated data. Individual borrowers only see their own financings. Organization admins see every financing in their organization and can filter by user_id or account_id.

Query parameters

ParamValuesNotes
statuspending · under_review · legal_agreement · signed · active · completed · cancelled · rejectedFilter by state
user_iduuidAdmin only · scope to one borrower
account_iduuidScope to one holding account
orderasc · descOrder by created_at
pageinteger, default 11-indexed
limitinteger, default 10Page size

The response wraps the list inside data with pagination metadata (count, currentPage, totalPages, limit). The single-fetch endpoint GET /:financing_id also returns member_contributions — a summary of prior installment payments — for clients identified as mobile.


StatusTypical triggerWhat it means · how to respond
400ValidationThe body fails schema validation — required field missing, wrong type, payback amount that doesn’t equal one installment, admin creating for a user outside their organization. Show an inline message.
401Missing or invalid bearer tokenToken expired, malformed, or absent. Re-authenticate and retry.
403Permission · ownership · stateCaller lacks the required permission, or is operating on a financing they don’t own, or attempting an illegal state transition (sign on a non-legal_agreement financing, cancel an active financing with non-zero holding, update a completed financing). Do not retry.
404Unknown idFinancing, user, or account does not exist or is soft-deleted. Refresh and drop stale entries.
409DuplicateA financing with the same (user_id, slug_product_name) already exists for this borrower. Differentiate the product name on your side before retrying.
500InternalRetry once with exponential backoff. If persistent, capture the request id from the error envelope and contact support.

Standard error envelope

{
"statusCode": 400,
"message": "Payback amount must equal one installment: expected 1000000.00, got 500000",
"error": "Bad Request"
}

Auth

  • Every endpoint expects an Authorization: Bearer <token> header
  • The token is a JWT issued by the platform auth flow — your client obtains it on behalf of the end-user and forwards it on every call
  • Each call is checked against the caller’s role and the permissions bound to that role
  • Admin-only endpoints (approve, withdraw, summary) additionally check that the caller’s role is ORGANIZATION or MORIA

Permission matrix

PermissionUsed by
create-commodity-financingPOST create
read-commodity-financingGET list · GET one · GET summary
update-commodity-financingPATCH update · PATCH approve · POST withdraw
sign-commodity-financingPOST sign
delete-commodity-financingDELETE
payback-commodity-financingPOST /v1/payments/balance · /gateway with reference_type: commodity_financing_payment

What isn’t supported, and how to work around it

Section titled “What isn’t supported, and how to work around it”

Commodity Financing composes with the rest of the Moria surface: documents pass through the document API, payback flows through the unified payments endpoints, and the per-financing holding account lives alongside every other Moria account in the same ledger. Knowing the lifecycle, the two create paths, the one-installment payback rule, and the admin-only withdraw is enough to ship a production integration.