Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.api.bsa.ai/llms.txt

Use this file to discover all available pages before exploring further.

This page collects behaviors that aren’t always obvious from the per-endpoint reference, drawn from live verification against the upstream LMS. Each section ends with the actual error text or response shape you’d see.

Loan state machine

A loan moves through five terminal states:
Submitted ─approve─► Approved ─disburse─► Active ─repay (full)─► Closed (obligations met)
   │ ▲                    │                    │
   │ │undo-approval       │undo-disbursal      └─over-repay──► Overpaid
   │ │                    │
   ├─reject ─────► Rejected (terminal)
   └─withdraw ───► Withdrawn (terminal)
LMS enforces this strictly. Calling an endpoint against a loan that’s in the wrong state returns HTTP 400 with a clear globalisation code:
Attempted actionLoan stateError you get
RepaySubmitted / ApprovedLoan must be Active, Fully Paid or Overpaid
ApproveApproved / ActiveLoan is already approved
DisburseSubmitted / ActiveLoan Account is not in approved and not disbursed state
ApproveActiveLoan Account is not in submitted and pending approval state
RejectApproved / ActiveLoan Account Reject is not allowed
When you need to roll a state back, use the explicit undo endpoints — /undo-approval and /undo-disbursal. These work like time machine: approval is rolled back to Submitted, disbursal to Approved. Rejected and Withdrawn are terminal — there is no path back.

Dates: what LMS accepts on writes

All dates are ISO yyyy-MM-dd on the wire. The service translates to LMS’s internal dd MMMM yyyy for you. Beyond that, LMS rejects two classes of dates on transactions:
Attempted dateBehavior
Earlier than the customer’s office transfer date (or loan disbursement)403 permission_deniedThe date on which a repayment or waiver is made cannot be earlier than client's transfer date to this office
Future (any day after today, UTC at LMS)403 permission_deniedThe transaction date cannot be in the future.
Malformed (not ISO)400 invalid_argumentmust be ISO yyyy-MM-dd (caught by the service, never reaches LMS)
Practical implications:
  • Don’t pre-post repayments for collection batches that haven’t settled yet — wait until the actual settlement date.
  • Back-dating is allowed only up to the loan’s disbursement date. Earlier-dated transactions are rejected.

Approving at a different amount than requested

POST /v1/loans/{id}/approve accepts an approvedLoanAmount that can differ from the originally submitted principal. LMS reduces the loan’s principal to the approved amount:
# Borrower submitted 10,000; underwriter approves 6,000
curl -X POST "$BASE/v1/loans/501/approve" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"approvedOnDate":"2026-05-23","approvedLoanAmount":6000}'

# Response (note: principal becomes 6,000, not 10,000)
{
  "id": "501", "status": "Approved",
  "principal": 6000, "approvedPrincipal": 6000, ...
}
The subsequent disbursement should be at the approved amount, not the submitted amount.

Overpayments

If a single repayment exceeds the outstanding balance, LMS:
  1. Applies the right amount to settle the loan completely
  2. Records the excess as overpaymentPortion on the transaction
  3. Transitions the loan to Overpaid (not Closed (obligations met))
Example: outstanding is 11,100, customer pays 12,000:
{
  "id": "22", "loanId": "11", "type": "Repayment",
  "amount": 12000,
  "principal": 10000,
  "interest": 1100,
  "overpaymentPortion": 900,
  "reversed": false
}
The loan account then sits in Overpaid with totalOutstanding: 0 and a 900 credit on the account.
The /refund endpoint does not clear overpayment credits. Calling refund on an Overpaid loan returns:
403 permission_denied — Loan Refund is not allowed. Loan Account is not active.
refundByCash (which is what /refund maps to) is for refunding during an active loan’s life, not for cashing out overpayments. Today there’s no public endpoint to settle an overpayment programmatically — the credit either gets applied to the customer’s next loan or has to be resolved via a back-office process in LMS.

Repayment allocation: penalties → fees → interest → principal

The default strategy on every Halotel loan product applies each repayment in strict order:
  1. Outstanding penalties
  2. Outstanding fees
  3. Outstanding interest
  4. Outstanding principal
If you don’t pass transactionProcessingStrategyCode on loan creation, this is what you get. This means a partial repayment that’s larger than the accrued interest clears the interest entirely before reducing principal. Example loan of 10,000 at 11% flat (interest = 1,100):
Payment 1: 2,000  →  interest=1100, principal=900   (clears all interest)
Payment 2: 3,000  →  interest=0,    principal=3000  (pure principal)
Payment 3: 6,100  →  interest=0,    principal=6100  (closes the loan)
The first payment carries the full interest. Subsequent payments are all principal until the loan is closed.

Reversal restores the balance exactly

POST /v1/loans/{id}/repayments/{txn}/reverse marks the transaction reversed: true and the original principal/interest split stays visible on the transaction record. The loan’s outstanding balance recomputes as if the transaction had never happened.
Before any payment: totalOutstanding = 11,100
After paying 5,000: totalOutstanding =  6,100
After reversing:    totalOutstanding = 11,100  ← restored
The original txn row is preserved (reversed: true) for the audit trail. There is no way to delete a transaction — reversal is the only “undo” mechanism for completed transactions.

Pagination boundaries

Pagination uses query params page (1-indexed) and rows. Both have strict caps; the service rejects out-of-range values with HTTP 400 before reaching LMS.
QueryBehavior
?page=1&rows=1OK — single-item page (smallest valid)
?page=999&rows=10 (no data on this page)OK — returns {"items":[],"total":N,"page":999,"rowsPerPage":10}
?page=0400 — page value too small, must be larger than 0
?rows=0400 — rows value too small, must be larger than 0
?rows=1000400 — rows value too large, must be less than 100
Upper bound on rows is 99, not configurable. Past-the-end pages return an empty items array rather than 404 — check the total field if you need to detect end-of-list.

Field-level validation errors

When you violate a field-level rule on a write, the response wraps the list of failures in the message:
{
  "code": "invalid_argument",
  "message": "lms: status=400 code=\"validation.msg.validation.errors.exist\": Validation errors exist. [firstname: The parameter `firstname` is mandatory.; lastname: The parameter `lastname` is mandatory.]"
}
Parse from the first [ to read each rejected field independently — see Errors → Field-level details for the full pattern.

A worked example: full happy path

To pin all of the above together — a complete create-customer-borrow-repay-close lifecycle on the Halotel 7-Day product (11% flat interest on 10,000 = 1,100 interest, total due 11,100):
# 1. Create customer (Person, active immediately)
CID=$(curl -sf -X POST "$BASE/v1/customers" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "officeId": 1, "legalFormId": 1,
    "firstname": "Ada", "lastname": "Lovelace",
    "mobileNo": "255700000000", "externalId": "ext-001",
    "active": true, "activationDate": "2026-05-23"
  }' | jq -r .id)

# 2. Submit loan
LID=$(curl -sf -X POST "$BASE/v1/loans" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"customerId\": $CID, \"productId\": 1, \"principal\": 10000,
    \"loanTermFrequency\": 7, \"loanTermFrequencyType\": 0,
    \"numberOfRepayments\": 1, \"repaymentEvery\": 7, \"repaymentFrequencyType\": 0,
    \"interestRatePerPeriod\": 11, \"amortizationType\": 1,
    \"interestType\": 1, \"interestCalculationPeriodType\": 1,
    \"transactionProcessingStrategyCode\": \"mifos-standard-strategy\",
    \"expectedDisbursementDate\": \"2026-05-23\",
    \"submittedOnDate\": \"2026-05-23\",
    \"loanType\": \"individual\"
  }" | jq -r .id)

# 3. Approve
curl -sf -X POST "$BASE/v1/loans/$LID/approve" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"approvedOnDate":"2026-05-23","approvedLoanAmount":10000}'

# 4. Disburse
curl -sf -X POST "$BASE/v1/loans/$LID/disburse" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"actualDisbursementDate":"2026-05-23","transactionAmount":10000}'

# 5. Repay in full (10,000 principal + 1,100 interest = 11,100)
curl -sf -X POST "$BASE/v1/loans/$LID/repayments" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "transactionDate": "2026-05-23",
    "transactionAmount": 11100,
    "paymentTypeId": 1,
    "receiptNumber": "RCP-001"
  }'

# Loan is now "Closed (obligations met)"