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.
| Property | Value |
|---|---|
| Audience | Partner integrators · backend engineers |
| Base URL | /v1 · JSON over HTTPS |
| Auth | Bearer token (JWT) · per-endpoint permissions |
| Lifecycle | pending · under_review · legal_agreement · signed · active · completed · rejected · cancelled |
| Currency | IDR · amounts as decimal-string |
| Integration | ~1–2 sprints for a typical mobile or web client |
What Commodity Financing does
Section titled “What Commodity Financing does”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.
Where money lives
Section titled “Where money lives”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.
Lifecycle and status transitions
Section titled “Lifecycle and status transitions”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
Endpoint reference
Section titled “Endpoint reference”| Method | Path | Purpose | Permission |
|---|---|---|---|
POST | /v1/commodity-financings | Create a financing (borrower or admin) | create-commodity-financing |
GET | /v1/commodity-financings | List financings (paginated, filterable) | read-commodity-financing |
GET | /v1/commodity-financings/:financing_id | Fetch one financing by id | read-commodity-financing |
PATCH | /v1/commodity-financings/:financing_id | Update editable fields (product, status, etc.) | update-commodity-financing |
POST | /v1/commodity-financings/:financing_id/sign | Borrower signs the legal agreement | sign-commodity-financing |
PATCH | /v1/commodity-financings/:financing_id/approve | Admin approves or rejects · sets terms | update-commodity-financing |
POST | /v1/commodity-financings/:financing_id/withdraw | Admin sweeps funds out of the holding account | update-commodity-financing |
DELETE | /v1/commodity-financings/:financing_id | Borrower cancels (only before active) | delete-commodity-financing |
GET | /v1/summary/commodity-financings | Organization-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.
Two create paths, one endpoint
Section titled “Two create paths, one endpoint”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
INDIVIDUALuser - Body follows the borrower DTO — no
user_idfield; 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
ORGANIZATIONorMORIAuser - Body follows the admin DTO — must include the borrower’s
user_id - The admin can pre-fill
approved_installment_amountandlegal_agreement_idup front - The borrower must belong to the admin’s organization (enforced server-side)
Creating a financing · borrower path
Section titled “Creating a financing · borrower path”Request
POST /v1/commodity-financingsAuthorization: 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.
Creating a financing · admin path
Section titled “Creating a financing · admin path”Request
POST /v1/commodity-financingsAuthorization: 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"}Paying an installment
Section titled “Paying an installment”---
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 }
Two payment rails · same reference
Section titled “Two payment rails · same reference”Balance rail · synchronous
POST /v1/payments/balanceAuthorization: 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/gatewayAuthorization: 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.
Admin approval flow
Section titled “Admin approval flow”Approve or reject
PATCH /v1/commodity-financings/:financing_id/approveAuthorization: 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/signAuthorization: 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
- Borrower or admin creates the financing — status starts as
pending - Admin reviews · calls
PATCH /:financing_id/approvewith statuslegal_agreementand the approved terms - Your client downloads or generates the agreement document and presents it to the borrower
- Borrower calls
POST /:financing_id/sign· status becomesactive - Borrower starts paying installments via
/v1/payments/balanceor/v1/payments/gateway
Admin sweeps the holding account
Section titled “Admin sweeps the holding account”Request
POST /v1/commodity-financings/:financing_id/withdrawAuthorization: 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.
List and filter
Section titled “List and filter”Request
GET /v1/commodity-financings?status=active &user_id=be2c-...-fb34 &page=1&limit=20Authorization: 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
| Param | Values | Notes |
|---|---|---|
status | pending · under_review · legal_agreement · signed · active · completed · cancelled · rejected | Filter by state |
user_id | uuid | Admin only · scope to one borrower |
account_id | uuid | Scope to one holding account |
order | asc · desc | Order by created_at |
page | integer, default 1 | 1-indexed |
limit | integer, default 10 | Page 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.
Errors you will encounter
Section titled “Errors you will encounter”| Status | Typical trigger | What it means · how to respond |
|---|---|---|
400 | Validation | The 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. |
401 | Missing or invalid bearer token | Token expired, malformed, or absent. Re-authenticate and retry. |
403 | Permission · ownership · state | Caller 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. |
404 | Unknown id | Financing, user, or account does not exist or is soft-deleted. Refresh and drop stale entries. |
409 | Duplicate | A financing with the same (user_id, slug_product_name) already exists for this borrower. Differentiate the product name on your side before retrying. |
500 | Internal | Retry 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, permissions, and visibility
Section titled “Auth, permissions, and visibility”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
ORGANIZATIONorMORIA
Permission matrix
| Permission | Used by |
|---|---|
create-commodity-financing | POST create |
read-commodity-financing | GET list · GET one · GET summary |
update-commodity-financing | PATCH update · PATCH approve · POST withdraw |
sign-commodity-financing | POST sign |
delete-commodity-financing | DELETE |
payback-commodity-financing | POST /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”Integration checklist
Section titled “Integration checklist”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.