/ Docs / Loan Booking
Page Docs new-loan
Overview Page Structure API Endpoints 1 · Application 2 · Loan Details 3 · Repayment Schedule 4 · Charges Calculations DSR Enforcement Active Loan Detection Submission Payload JS State Variables Functions Validation Rules
Page Documentation new-loan

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.

Customer Search Live EMI & DSR Repayment Preview DSR Enforcement

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.

1 Select Customer
2 Enter Loan Details
3 Set Repayment Date
4 Add Charges
5 Submit

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.

Section 1 — Application
Blue stripe · Customer lookup, net salary, loan amount, tenor

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.

Section 2 — Loan Details
Green stripe · State, Ministry, Sub-Ministry, loan type, sector, outstanding

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.

Section 3 — Repayment Schedule
Rust stripe · First repayment month/year, live schedule preview

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.

Section 4 — Charges
Teal gradient stripe · DSR stats, charges breakdown table, disburse amount

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.

GETapi/location_api?type=statesPopulates the State dropdown on load
GETapi/ministry_api?state={id}Loads ministries for a selected state
GETapi/sub_ministry_api?min_fk={id}&status=activeLoads sub-ministries for a selected ministry
GETapi/customer_api?type=loan_search&search={q}&limit=10Live customer autocomplete search
GETapi/settings_apiLoads DSR limit and maintenance fee config
GETapi/charges_api?type=listLoads all available charge options
POSTapi/loan_apiSubmits the completed loan payload
Ministry interest rate Each ministry option in the 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).

FieldTypeRequiredDescription
customerSearchtextRequiredAutocomplete input. Debounced 300 ms. Calls api/customer_api?type=loan_search. Replaced by selected-customer chip on selection.
customerIdhiddenRequiredHidden field set to userid or cust_id from the API response when a customer is selected.
fNetSalarynumberOptionalPre-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

FieldTypeRequiredDescription
fLoanAmountnumberRequiredPrincipal loan amount in Naira. Triggers recalculate() on every keystroke. All charge amounts recompute proportionally.
fTenorselectRequiredRepayment 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

Cascade order: State → Ministry (with interest %) → Sub-Ministry. Each level is disabled until the parent is selected. Selecting a new state resets and disables both downstream selects and clears ministryInterestPct to zero.
FieldTypeRequiredDescription
fStateselectRequiredPopulated on page load from api/location_api?type=states. Triggers ministry load on change.
fMinistryselectOptionalLoaded from api/ministry_api?state=. Each option carries data-pct (annual interest %). Selecting one sets ministryInterestPct and triggers recalculate().
fSubMinistryselectOptionalLoaded from api/sub_ministry_api?min_fk=&status=active. Purely a classification field — not used in calculations.

B Classification & Outstanding

FieldTypeRequiredDescription
fLoanTypeselectOptionalOptions: new, top_up, refinance. Auto-set to top_up when an existing disbursed loan is found for the customer.
fSectorselectOptionalOptions: state, federal, private, lga. Classification only — does not affect calculations.
fOutstandingnumberOptionalOutstanding 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.

FieldTypeRequiredDescription
fRepayMonthselectRequiredJanuary–December, values 1–12. Triggers updateRepayPreview() on change.
fRepayYearselectRequiredCurrent year through current year + 5. Populated by populateYearDropdown() at init. Triggers updateRepayPreview() on change.
fRepayDateDisplaytext readonlyDisplay onlyShows 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.

updateRepayPreview() JavaScript
// 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

addCharge() — amount derivation JavaScript
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:

totalChargesVal
Sum of all added charges (rate + fixed). Does not include the ministry interest — that is baked into the EMI.
disburseVal
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.

EMI — Equated Monthly Instalment
monthlyPrincipal = principal / tenorMonths monthlyInterest = (annualPct / 12 / 100) × principal EMI = monthlyPrincipal + monthlyInterest
Flat rate, not reducing balance The interest component is the same every month regardless of how much has been repaid. This means the total interest paid equals monthlyInterest × tenor.

2 DSR Formula

DSR — Debt Service Ratio
DSR% = (EMI / netSalary) × 100

3 Maintenance Fee

Maintenance (from settings)
if val < 100: maintenance = (val / 100) × loanAmount ← treat as % if val ≥ 100: maintenance = val ← treat as fixed ₦

4 Disburse Amount

Net Disburse
disburse = max(0, loanAmount totalCharges outstanding)

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.

DSR OK

Green — DSR is within limit (and below 80% of the threshold). Submit button is fully enabled.

DSR 27.4% / 33%

Amber — DSR exceeds 80% of the limit. A warning badge appears but submission is still allowed.

DSR 41.2% — OVER LIMIT

Red — DSR has exceeded the cap. The alert banner appears, the Submit button turns red and is disabled.

Double-enforced block The Submit button is both visually blocked (.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.

checkActiveLoan() — flow JavaScript
// 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.

Top-Up auto-close When a Top-Up loan is submitted, the backend (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.

POST payload — api/loan_api JSON
{
  "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

KeyTypeNotes
customer_idstringFrom hidden input. Required — validated before POST.
loan_repayment_datestringAlways day 01. Format YYYY-MM-01. Backend derives the 29th deduction date from this.
chargesarrayEach entry is inserted into the loan_charges audit table by the backend.
interest_feenumbermonthlyInterest × tenor. Total interest for the full loan term.
disburse_amountnumberFloor at zero. This is the net cash the borrower receives.
dsrstringSent as a string to preserve exactly 2 decimal places (e.g. "32.78").

B Success Response

api/loan_api — success response JSON
{
  "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

VariableTypeDescription
selectedCustomerobject|nullThe full customer object from the API. null when no customer is selected. Reset to null by clearCustomer().
appChargesarrayRunning list of added charges: [{id, name, pct, fixed, amount}]. Recomputed on each recalculate() call when loan amount changes.
appSettingsobjectSettings loaded from api/settings_api. Keys used: app_dsr (DSR limit), maintenance_fee (maintenance config).
ministryInterestPctnumberAnnual interest rate (%) from the selected ministry's min_percent field. Zero if no ministry is selected. Drives the EMI calculation.
dsrExceededbooleanSet by recalculate() on every change. When true, the submit button is blocked and submitLoan() returns early.
searchTimertimeoutDebounce timer reference for the customer search input (300 ms delay).

Key Functions

FunctionTriggerDescription
init()Page loadEntry point. Calls populateYearDropdown() then concurrently fires loadStates(), loadSettings(), and loadChargeOptions() via Promise.all.
recalculate()Any input changeCore 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 clickPopulates customer chip UI, sets hidden customerId, pre-fills net salary, then calls checkActiveLoan() and recalculate().
checkActiveLoan(id)selectCustomerAsync. 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 selectResets ministry and sub-ministry dropdowns, clears ministryInterestPct, loads fresh ministries from API for the chosen state.
onMinistryChange(sel)Ministry selectReads data-pct from the chosen option into ministryInterestPct, triggers recalculate(), then loads sub-ministries.
addCharge(sel)Charge selectPrevents duplicates. Reads data-pct / data-fixed from the option, computes amount, pushes to appCharges, re-renders table, calls recalculate().
submitLoan()Submit buttonValidates required fields, builds payload, POSTs to api/loan_api. Disables button during request. On success, redirects to loan-documents.
updateRepayPreview()Month/Year changeUpdates the readonly date display field and renders the schedule preview grid with EMI amounts across key months.
calcEMI(p, r, t)InternalPure function. Returns 0 if principal or tenor is falsy. p = principal, r = annual interest %, t = months.
calcMaintenance(raw, loan)InternalParses 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.

CheckFieldError Message
DSR guardSubmission blocked: DSR limit exceeded. Please reduce the loan amount or increase the tenor.
Customer requiredcustomerIdPlease select a customer.
Loan amount requiredfLoanAmountLoan amount is required.
State requiredfStatePlease select a state.
Repay month requiredfRepayMonthPlease select the first repayment month.
Server-side validation 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.