Loan Booking
End-to-end reference for the loan creation form — covering all four sections, real-time calculations, DSR enforcement, active loan detection, and the submission payload sent to api/loan_api.
What This Page Does
The Loan Booking page (new-loan) is the primary form for creating new loan records in Sodlapp. A loan officer fills in four sequential sections — applicant details, loan parameters, repayment schedule, and applicable charges — before submitting the completed record to the backend.
All key financial figures (EMI, DSR, disburse amount, total charges) update in real time as fields are changed. Submission is blocked if the computed Debt-Service Ratio exceeds the system-wide limit configured in App Settings.
On successful submission, the user is redirected to loan-documents?loan_id=<id>&new=1 where they can download and print the loan agreement. If the new loan was a Top-Up that automatically closed an existing disbursed loan, a toast notification confirms the closed serial number.
Page Structure
The form is split into four visually distinct sections, each rendered as a white card with a colour-coded 3px top stripe and a matching section icon.
The customer is selected via an autocomplete live-search input. Once chosen, their net salary is pre-populated and the system automatically checks for any existing disbursed loan.
State selection cascades to load Ministries from api/ministry_api; selecting a ministry cascades further to load Sub-Ministries and also sets the ministryInterestPct used in EMI calculation.
Two dropdowns (month + year) generate a YYYY-MM-01 date string. A live preview grid shows the first 3 EMI payments plus the final payment, labelled with calendar month and year.
Charges are loaded from api/charges_api and applied to a running table. The Submit button lives here and is disabled/blocked when DSR exceeds the system limit.
API Endpoints Consumed
Seven PHP API files are consumed by this page. All return {"success": true/false, "data": ...} JSON envelopes.
api/ministry_api response includes a min_percent field. This annual interest percentage is stored in ministryInterestPct and drives the EMI calculation whenever a ministry is selected.Application
A Customer Search
Typing into the search field triggers searchCustomers(q) after a 300 ms debounce. Results appear in an absolute-positioned dropdown; clicking an item calls selectCustomer(c).
| Field | Type | Required | Description |
|---|---|---|---|
| customerSearch | text | Required | Autocomplete input. Debounced 300 ms. Calls api/customer_api?type=loan_search. Replaced by selected-customer chip on selection. |
| customerId | hidden | Required | Hidden field set to userid or cust_id from the API response when a customer is selected. |
| fNetSalary | number | Optional | Pre-filled from the customer's net_salary on selection. Used for DSR computation. Accepts formatted values with commas; stripped on calculation. |
B Core Loan Parameters
| Field | Type | Required | Description |
|---|---|---|---|
| fLoanAmount | number | Required | Principal loan amount in Naira. Triggers recalculate() on every keystroke. All charge amounts recompute proportionally. |
| fTenor | select | Required | Repayment tenor in months (1–60). PHP loop generates all 60 options server-side. Triggers recalculate() on change. |
Loan Details
This section handles the Ministry cascade and loan classification fields.
A Cascading Location Dropdowns
ministryInterestPct to zero.| Field | Type | Required | Description |
|---|---|---|---|
| fState | select | Required | Populated on page load from api/location_api?type=states. Triggers ministry load on change. |
| fMinistry | select | Optional | Loaded from api/ministry_api?state=. Each option carries data-pct (annual interest %). Selecting one sets ministryInterestPct and triggers recalculate(). |
| fSubMinistry | select | Optional | Loaded from api/sub_ministry_api?min_fk=&status=active. Purely a classification field — not used in calculations. |
B Classification & Outstanding
| Field | Type | Required | Description |
|---|---|---|---|
| fLoanType | select | Optional | Options: new, top_up, refinance. Auto-set to top_up when an existing disbursed loan is found for the customer. |
| fSector | select | Optional | Options: state, federal, private, lga. Classification only — does not affect calculations. |
| fOutstanding | number | Optional | Outstanding balance from a prior loan. Deducted from the disburse amount: disburse = loanAmount − charges − outstanding. Auto-filled when an active disbursed loan is detected. |
Repayment Schedule
Two dropdowns (month and year) define the first repayment date. The system defaults these to the next calendar month on page load. A readonly display field shows the computed date as a human-friendly label (e.g. "29 August 2025"). The 29th is used as the standard monthly deduction date.
| Field | Type | Required | Description |
|---|---|---|---|
| fRepayMonth | select | Required | January–December, values 1–12. Triggers updateRepayPreview() on change. |
| fRepayYear | select | Required | Current year through current year + 5. Populated by populateYearDropdown() at init. Triggers updateRepayPreview() on change. |
| fRepayDateDisplay | text readonly | Display only | Shows formatted date (e.g. 29 August 2025). Value is never submitted — getRepayDate() builds the ISO string for the payload. |
A Schedule Preview
When both a loan amount and a repayment date are set, a preview panel appears beneath the date fields showing the computed EMI across key months: payments 1, 2, 3, and the final payment (with an opacity dim). If the tenor is 3 months or fewer, all months are shown. The preview regenerates on every change to fLoanAmount, fTenor, or either date dropdown.
// Indices shown: [0, 1, 2, tenor-1] (unique, deduped) const indices = tenor <= 3 ? Array.from({length: tenor}, (_,i) => i) : [0, 1, 2, tenor - 1]; const uniqueIdx = [...new Set(indices)]; // Each item shows: EMI amount, calendar label // Final item is visually dimmed (opacity: 0.7)
Charges
Charges are loaded from api/charges_api?type=list on page init. Each charge has a rate_type of either "rate" (percentage of loan amount) or "fixed" (fixed Naira amount). They are selected one at a time via a dropdown — the same charge cannot be added twice.
A Charge Calculation
const amt = fixed > 0 ? fixed // fixed Naira amount : (pct / 100) * loan; // % of loan principal // Amounts recompute whenever loanAmount changes (in recalculate()) appCharges = appCharges.map(c => ({ ...c, amount: c.fixed > 0 ? c.fixed : (c.pct / 100) * loan }));
B Summary Cards
Two amount cards sit above the charges table and update in real time:
max(0, loanAmount − totalCharges − outstanding). The net cash disbursed to the borrower after deductions.Financial Calculations
1 EMI Formula
The EMI uses a flat-rate model — interest is computed as a fixed monthly amount on the full original principal (not a declining balance), then added to the monthly principal repayment.
monthlyInterest × tenor.2 DSR Formula
3 Maintenance Fee
4 Disburse Amount
DSR Enforcement
The DSR limit is loaded from api/settings_api into appSettings.app_dsr. The system supports a percentage string (e.g. "33%") or a plain number — both are parsed identically. If no limit is configured, DSR is shown as information only and does not block submission.
Green — DSR is within limit (and below 80% of the threshold). Submit button is fully enabled.
Amber — DSR exceeds 80% of the limit. A warning badge appears but submission is still allowed.
Red — DSR has exceeded the cap. The alert banner appears, the Submit button turns red and is disabled.
.blocked class, rust red) and functionally disabled. Even if a user calls submitLoan() directly via console, the if (dsrExceeded) guard at the top of the function returns early and shows an error.Active Loan Detection
Immediately after a customer is selected, checkActiveLoan(customerId) runs in the background. It fetches all disbursed loans via api/loan_api?type=list&status=Disbursed&per_page=500 and looks for one matching the customer's ID.
// 1. Fetch all Disbursed loans const activeLoan = loans.find(l => String(l.customer_fk) === String(customerId) && l.loan_status === 'Disbursed' ); // 2. If found, fetch full loan detail for balance const outstanding = parseFloat( loan.balance ?? loan.outstanding ?? loan.loan_amount ) || 0; // 3. Auto-fill Outstanding field + set Loan Type = Top-Up // 4. Show the blue Active Loan Notice banner
The notice banner displays a mini-table with: original loan amount, total paid, outstanding balance, paid percentage, payment count, and last payment date. It is automatically hidden when the customer is cleared via the × button.
api/loan_api) automatically closes the previous disbursed loan. On success, if the response contains data.old_loan_closed = true, the UI shows a toast with the closed serial number.Submission Payload
On submit, a JSON payload is POSTed to api/loan_api. The full payload structure is below.
{
"customer_id": "42",
"loan_amount": 500000,
"tenor": 12,
"net_salary": 150000,
"state": "3",
"ministry": "17",
"sub_ministry": "5",
"loan_type": "new",
"sector": "state",
"outstanding": 0,
"loan_repayment_date": "2025-09-01",
"charges": [
{
"charge_id": "2",
"charge_name": "Management Fee",
"charge_pct": 2.5,
"charge_fixed": 0,
"charge_amt": 12500,
"rate_type": "rate"
}
],
"total_charges": 12500,
"ministry_interest_pct": 18,
"interest_fee": 90000,
"disburse_amount": 487500,
"emi": 49166.67,
"maintenance": 0,
"dsr": "32.78"
}
A Payload Fields Reference
| Key | Type | Notes |
|---|---|---|
| customer_id | string | From hidden input. Required — validated before POST. |
| loan_repayment_date | string | Always day 01. Format YYYY-MM-01. Backend derives the 29th deduction date from this. |
| charges | array | Each entry is inserted into the loan_charges audit table by the backend. |
| interest_fee | number | monthlyInterest × tenor. Total interest for the full loan term. |
| disburse_amount | number | Floor at zero. This is the net cash the borrower receives. |
| dsr | string | Sent as a string to preserve exactly 2 decimal places (e.g. "32.78"). |
B Success Response
{
"success": true,
"data": {
"loan_id": 187,
"old_loan_closed": true,
"closed_serial": "LN-2025-0041"
}
}
On success, the UI waits 1.2 seconds then redirects to loan-documents?loan_id={id}&new=1. If no loan_id is returned, it falls back to manage-loans.
JavaScript State Variables
| Variable | Type | Description |
|---|---|---|
| selectedCustomer | object|null | The full customer object from the API. null when no customer is selected. Reset to null by clearCustomer(). |
| appCharges | array | Running list of added charges: [{id, name, pct, fixed, amount}]. Recomputed on each recalculate() call when loan amount changes. |
| appSettings | object | Settings loaded from api/settings_api. Keys used: app_dsr (DSR limit), maintenance_fee (maintenance config). |
| ministryInterestPct | number | Annual interest rate (%) from the selected ministry's min_percent field. Zero if no ministry is selected. Drives the EMI calculation. |
| dsrExceeded | boolean | Set by recalculate() on every change. When true, the submit button is blocked and submitLoan() returns early. |
| searchTimer | timeout | Debounce timer reference for the customer search input (300 ms delay). |
Key Functions
| Function | Trigger | Description |
|---|---|---|
| init() | Page load | Entry point. Calls populateYearDropdown() then concurrently fires loadStates(), loadSettings(), and loadChargeOptions() via Promise.all. |
| recalculate() | Any input change | Core calculation function. Recomputes charge amounts, EMI, DSR, total charges, and disburse amount. Updates all display elements and DSR badge state. Calls updateRepayPreview() at the end. |
| selectCustomer(c) | Dropdown click | Populates customer chip UI, sets hidden customerId, pre-fills net salary, then calls checkActiveLoan() and recalculate(). |
| checkActiveLoan(id) | selectCustomer | Async. Fetches disbursed loans, finds a match for the customer, fetches balance, auto-fills Outstanding and sets Loan Type to Top-Up, shows notice banner. |
| onStateChange(sel) | State select | Resets ministry and sub-ministry dropdowns, clears ministryInterestPct, loads fresh ministries from API for the chosen state. |
| onMinistryChange(sel) | Ministry select | Reads data-pct from the chosen option into ministryInterestPct, triggers recalculate(), then loads sub-ministries. |
| addCharge(sel) | Charge select | Prevents duplicates. Reads data-pct / data-fixed from the option, computes amount, pushes to appCharges, re-renders table, calls recalculate(). |
| submitLoan() | Submit button | Validates required fields, builds payload, POSTs to api/loan_api. Disables button during request. On success, redirects to loan-documents. |
| updateRepayPreview() | Month/Year change | Updates the readonly date display field and renders the schedule preview grid with EMI amounts across key months. |
| calcEMI(p, r, t) | Internal | Pure function. Returns 0 if principal or tenor is falsy. p = principal, r = annual interest %, t = months. |
| calcMaintenance(raw, loan) | Internal | Parses raw (strips %). If <100 treats as %, otherwise treats as fixed Naira amount. |
Validation Rules
Client-side validation runs inside submitLoan() before the API call. Failed fields are highlighted with a red border (1.6 s auto-reset) via hl(id) and an error banner appears at the top of the page.
| Check | Field | Error Message |
|---|---|---|
| DSR guard | — | Submission blocked: DSR limit exceeded. Please reduce the loan amount or increase the tenor. |
| Customer required | customerId | Please select a customer. |
| Loan amount required | fLoanAmount | Loan amount is required. |
| State required | fState | Please select a state. |
| Repay month required | fRepayMonth | Please select the first repayment month. |
api/loan_api should also independently validate all required fields. Any success: false response is caught in the try/catch and displayed to the user via the error banner and toast notification.