/ Docs / Import Loans
Page Docs import-loans.php
Overview Page Structure API Endpoints Step 1 — Configure Step 2 — Preview Step 3 — Import Resolution Priority Serial Generation CSV Columns Preview Row Schema DB Insert Fields JS Variables JS Functions Error Handling
Page Documentation import-loans.php

Import Loans

Complete reference for the bulk loan import page — a 3-step wizard covering CSV/XLSX file parsing, IPPS customer matching, ministry and interest rate resolution, auto serial generation, and confirmed database insertion.

CSV / XLSX Upload IPPS Matching Rate Resolution Ministry Override 3-Step Wizard

What This Page Does

The Import Loans page (import-loans.php) provides a 3-step wizard for bulk-importing loan records via CSV, XLSX, or XLS file upload. Each row is matched to an existing customer using their IPPS number, validated, and inserted into the loans table with auto-generated serial numbers.

The page handles both GET (render the UI) and POST (AJAX actions) in a single file. POST requests are detected immediately on load via $_SERVER['REQUEST_METHOD'], and JSON output is locked down before any includes can contaminate the response.

1 Select branch + ministry
2 Upload CSV/XLSX
3 Preview & validate rows
4 Review summary
5 Confirm insert

Page Structure

The page renders three step panels (#step1, #step2, #step3) only one of which is visible at a time. A visual progress bar above them reflects the current step state (active / done).

1
Configure
Branch, ministry & file upload
2
Preview
IPPS match & row validation
3
Import
Summary confirmation & insert
Step 1 — Configure
#step1 · Branch/ministry selects, date picker, drop-zone, CSV reference table

Contains a template download button, an IPPS info box, a 3-column form (branch, ministry, date), a drag-and-drop file zone, and a collapsible CSV column reference table. The Preview button is disabled until both a branch and file are selected.

Step 2 — Preview
#step2 · Stats strip + 14-column validation table

Fetches action=preview via FormData. Renders a stats strip (Total / Ready / Errors / Warnings) and a scrollable table showing every parsed row with its resolved values, inline error/warning messages, and a status badge. Error rows can be exported to a separate CSV.

Step 3 — Confirm & Import
#step3 · Summary grid, progress bar, result panel

Displays a summary grid (row count, total value, branch, ministry, rate) plus a skipped-row warning. On confirm, sends action=import via FormData with the validated rows as JSON. A simulated progress bar runs during the request. On completion, a result panel replaces the action buttons.

API Endpoints

Both actions are self-hosted — they POST to the current page URL ('' in the JS fetch). The action field in the FormData body distinguishes the operation.

POST import-loans.php action=preview · multipart/form-data with file
POST import-loans.php action=import · application/x-www-form-urlencoded with rows JSON
Output buffering on all POST requests The file calls ob_start() before any includes so that PHP notices, session redirects, or stray whitespace cannot corrupt the JSON response. A shutdown function catches fatal errors and encodes them as JSON too.

A Preview Request Fields

FieldTypeDescription
actionstringAlways "preview"
branch_idintSelected branch ID. Required — returns error if missing.
ministry_idintOptional global ministry override. 0 if none selected.
default_ratedecimal stringSourced from the selected ministry's min_percent. Falls back to "2".
import_filefileThe uploaded CSV, XLSX, or XLS file.

B Import Request Fields

FieldTypeDescription
actionstringAlways "import"
branch_idintSame branch selected in Step 1.
ministry_idintSame ministry override (or 0).
rowsJSON stringJSON-encoded array of preview row objects. Only rows with status === "ok" are processed.

Step 1 — Configure

The opening step collects the three inputs required before parsing can begin: target branch, optional ministry override, and the file itself. The Preview button remains disabled until both branch and file are set — enforced by checkBtn() which runs on every change event.

A Branch Selection

Selecting a branch fires onBranchChange(). This reads branchMinistry[branchId] — a PHP-injected JS map of branch_id → ministry_fk — and auto-selects the linked ministry in the ministry dropdown. The ministry selection then triggers onMinistryChange() to display the rate badge.

onBranchChange() — auto-fills linked ministry JavaScript
function onBranchChange(sel) {
  checkBtn();
  const branchId  = parseInt(sel.value) || 0;
  const linkedMin = branchMinistry[branchId] || 0;
  const minSel    = document.getElementById('ministrySelect');

  if (linkedMin && minSel) {
    minSel.value = linkedMin;
    onMinistryChange(minSel, true); // fromBranch=true → different hint text
  }
}

B Ministry Override

When a ministry is selected (either manually or via branch auto-fill), onMinistryChange() highlights the dropdown with an override-active CSS class, updates the hint text, and shows a rate badge displaying the ministry's min_percent value sourced from the ministryData JS map.

C File Drop Zone

Accepts CSV, XLSX, and XLS files up to 5 MB. Supports both click-to-browse and drag-and-drop. On drop, the file is injected into the hidden <input type="file"> via a DataTransfer object so the same fileSelected() handler path runs for both methods.

XLSX requires PhpSpreadsheet If vendor/autoload.php is not present, the server will reject XLSX/XLS uploads with an error message. CSV files work with no external dependencies. Ensure Composer dependencies are installed for Excel support.

Step 2 — Preview

Triggered by clicking "Preview Import". The JS doPreview() function builds a FormData object, immediately transitions to Step 2, shows a skeleton loading state, and POSTs to action=preview. The PHP handler parses the file, matches every IPPS against the customers table, resolves ministry/rate/branch, computes loan financials, and returns a JSON array of preview row objects.

A Stats Strip

Total Rows
All parsed rows
Ready
No errors
Errors
Will be skipped
Warnings
Non-blocking

B Preview Table Columns

ColumnSourceNotes
#Row index (1-based)Displayed as the original file row number.
IPPSr.ippsRendered in monospace accent color.
Customerr.customer_nameFull name + phone from matched customer record. Error/warning text appears under this cell.
Serialr.loan_serialPre-assigned auto-serial (date + company ID + counter). Purple monospace.
Branchr.branch_idResolved via branchNames JS map.
Ministryr.ministry_fkResolved via ministryNames JS map. Shows dash if unresolved.
Loan Amountr.loan_amountRight-aligned, bold. Formatted as ₦ with commas.
EMIr.loan_emiMonthly installment = (principal + interest) / tenor.
Rater.loan_pctMonthly interest rate percentage. Purple, bold.
Tenorr.tenorMonths, e.g. 12m.
Typer.loan_typeNew / Top-up / Repeat chip badge.
Sectorr.sectorState / Federal / Others chip badge.
Dater.loan_datedYYYY-MM-DD disbursement date.
Statusr.status✓ OK (green) or ✗ Error (rust) badge.

C Error Export

If any rows have errors, a "Export Errors" button appears. Clicking it calls downloadErrors() which builds a CSV in memory from all error rows and triggers a browser download as import_errors.csv. No server round-trip needed.

Step 3 — Confirm & Import

Navigating to Step 3 calls buildSummary(ok) which computes total loan value and renders a summary grid. Only rows with status === "ok" are counted here; error rows are silently excluded.

A Summary Grid

Five cells: Loans to Import (count), Total Loan Value (₦ formatted), Target Branch, Ministry, and Interest Rate. If any rows will be skipped, a gold warning box below the grid states how many and why.

B Import Execution

Clicking "Import Now" calls doImport(). A simulated progress bar increments to 88% every 60 ms while the fetch is in-flight, then jumps to 100% on completion. The import button and Back button are both hidden once the import completes to prevent double-submission via the importDone guard flag.

doImport() — double-submit guard JavaScript
async function doImport() {
  if (importDone) return;   // guard against double-click
  // ...
  importDone = true;          // set immediately on success
  document.getElementById('importActions').style.display = 'none';
}

C Result Panel

On success, showResult('success', count, msg) renders a large ✓ count, a subtitle with the success message, and two action buttons — "View All Loans" (link to loans.php) and "Import More" (reloads the page). On failure, a ✗ panel with the error message is shown instead.

Partial success is possible The PHP import action commits each row individually inside a transaction. If specific rows fail (e.g. duplicate key), they are added to a failedRows array returned alongside the imported count. The result panel lists each failed row with its error message.

Resolution Priority

Ministry, interest rate, and branch can each come from multiple sources. The PHP preview handler applies a strict priority chain so the most-specific source always wins.

A Ministry Resolution

1
Global UI Override
ministrySelect value
2
Per-Row CSV Column
ministry column in file
3
Customer Record
(fallback / not set)
Global override silences per-row ministry If a ministry is selected in the UI, any ministry column in the CSV file is completely ignored for all rows. Per-row overrides only apply when the global ministry dropdown is left blank.

B Interest Rate Resolution

1
Per-Row CSV
loan_percentage column
2
Ministry min_percent
resolved ministry's rate
3
default_rate
passed from JS (default 2%)
Rate resolution — PHP preview handler PHP
// Priority 1: explicit CSV column
$loan_pct_raw = trim($r['loan_percentage'] ?? $r['interest_rate'] ?? '');

// Priority 2 + 3: ministry rate or default
$loan_pct = (is_numeric($loan_pct_raw) && floatval($loan_pct_raw) > 0)
    ? $loan_pct_raw
    : $min_pct;   // $min_pct already resolves ministry → default

C Branch Resolution

The global branch selected in the UI is the default for all rows. A per-row branch column (name or code) can override this for individual rows — the PHP handler looks it up in a $branchMap keyed by both strtolower(branch_name) and strtolower(branch_code). If no per-row branch column exists, the global branch ID is used.

Serial Generation

Loan serials are auto-generated — they must not be included in the CSV file. Each serial is unique within the company and sequential from the current loan count.

Serial format — PHP import handler PHP
// Format: YYYYMMDD-{comp_id padded 3}-{count padded 6}
$serial = date('Ymd')
  . '-'
  . str_pad($cp_id, 3, '0', STR_PAD_LEFT)
  . str_pad($loanCount, 6, '0', STR_PAD_LEFT);
$loanCount++;   // increments for each row in the loop

Example: 20250115-002-000047 — company 2, the 47th loan. The preview step pre-assigns serials so they are visible before confirmation, but the import step re-generates them fresh (starting from the live count at import time) to account for any loans added between preview and confirm.

Preview serials may differ from import serials If loans are added by another user between the Preview and Import steps, the loan count will have shifted and serials will be re-numbered. The preview serials are indicative only.

CSV Column Reference

Column names are trimmed and matched case-insensitively. A BOM (\xEF\xBB\xBF) is stripped from the first column header automatically. Alternate aliases (ipps_number, net_pay, interest_rate, etc.) are also accepted.

ColumnRequiredExampleNotes
ipps Required IPPS001234 Must match a customer record's ipps field for this company. Also accepts ipps_number, IPPS.
loan_amount Required 500000 Principal amount. Non-numeric characters (commas, ₦ symbol) are stripped. Must be > 0.
tenor Required 12 Repayment months as integer. Must be > 0. Also accepts months.
netpay Required 85000 Monthly net pay. Generates a warning (not an error) if zero. Also accepts net_pay, salary.
loan_percentage Optional 2.5 Per-row monthly interest rate %. Overrides ministry rate if provided and > 0. Also accepts interest_rate.
loan_type Optional New Fuzzy-matched: contains "top" → Top-up, contains "rep" → Repeat, else → New.
sector Optional State Fuzzy-matched: "fed" → Federal, "other" → Others, else → State.
loan_dated Optional 2025-01-15 Disbursement date, any format parseable by PHP's strtotime(). Defaults to today.
disbursed_amt Optional 490000 Actual disbursed amount. Defaults to loan_amount if blank.
ministry Optional Finance Per-row ministry name override. Ignored if global ministry override is set. Case-insensitive name match.
branch Optional Abuja HQ Per-row branch name or branch code override. Matched against both name and code (case-insensitive).

Preview Row Schema

Each object in the rows array returned by action=preview — and stored in the JS previewRows variable — has the following shape.

KeyTypeDescription
rowint1-based row index from the original file.
ippsstringRaw IPPS value from the CSV row.
customer_idint|nullMatched customer's userid. null if no IPPS match.
customer_namestringCustomer's full name. "—" if unmatched.
customer_phonestringCustomer phone number (display only).
loan_serialstringPre-assigned serial number for display. Re-generated at actual import time.
loan_amountfloatParsed principal amount.
tenorintRepayment months.
netpayfloatMonthly net pay.
loan_pctstringResolved interest rate (string to preserve decimal precision).
loan_emifloatMonthly installment = (principal + interest) / tenor, rounded to 2dp.
interestfloatTotal interest = principal × (rate/100) × tenor, rounded to 2dp.
disbursed_amtfloatActual disbursed amount.
loan_datedstringDisbursement date as YYYY-MM-DD.
loan_typestringNew / Top-up / Repeat.
sectorstringState / Federal / Others.
ministry_fkint|nullResolved ministry ID. null if unresolved.
subministry_fkint|nullFrom customer's subministry_fk field (if present).
state_fkint|nullCustomer's state_id.
branch_idintResolved branch ID (per-row override or global selection).
errorsarrayArray of error message strings. Non-empty → status = "error".
warningsarrayArray of warning strings. Non-blocking — row is still imported.
statusstring"ok" or "error". Only "ok" rows are sent to the import action.

Database Insert Fields

The action=import handler inserts into the loans table using a prepared statement with 22 bound parameters. All loans are inserted with loan_status = 'disbursed'.

ColumnTypeSource
loan_serialstringRe-generated at import time from current loan count.
comp_fkstringSession $cp_id — never from the client.
customer_fkintr['customer_id'] cast to int.
netpayfloatr['netpay']
loan_amountfloatr['loan_amount']
tenorintr['tenor']
state_fkint|nullr['state_fk'] — null if missing, not 0.
loan_percentagefloatr['loan_pct']
disbursed_amtfloatr['disbursed_amt']
loan_princfloatSame as loan_amount.
loan_interestfloatr['interest']
loan_emifloatr['loan_emi']
ministry_fkint|nullGlobal override takes priority; falls back to r['ministry_fk']. Null if unresolved.
subministry_fkint|nullr['subministry_fk'] — null if missing.
loan_branch_fkintr['branch_id'] or fallback to POST branch_id.
loan_typestringNew / Top-up / Repeat
sectorstringState / Federal / Others
outstandingfloatloan_amount + loan_interest, rounded to 2dp.
loan_statusstringAlways 'disbursed' for imported loans.
booked_bystring$_SESSION['username'] or 'import' if session missing.
loan_datedstringDisbursement date YYYY-MM-DD.
loan_timestringCurrent datetime at import execution.
loan_created_atdatetimeNOW() — set by MySQL, not bound.
Nullable ints must be null, not 0 state_fk, ministry_fk, and subministry_fk are explicitly set to null (not 0) when missing. The bind_param type string is 'ssiddiidddddiiissdssss' — 22 characters matching the 22 INSERT placeholders.

JavaScript State Variables

VariableTypeDescription
previewRowsarrayFull array of preview row objects from the last action=preview response. Both renderPreview() and buildSummary() read from this. Populated by doPreview().
currentStepnumberActive step number (1, 2, or 3). Updated by goStep(n).
importDonebooleanSet to true once a successful import completes. Guards doImport() against re-execution.
branchNamesobjectPHP-injected map of branch_id → display name. Used in preview table to show branch labels from IDs.
ministryDataobjectPHP-injected map of min_id → { name, pct }. Used for rate badge display and summary grid.
branchMinistryobjectPHP-injected map of branch_id → ministry_fk. Used by onBranchChange() to auto-fill ministry dropdown.
ministryNamesobjectDerived from ministryData as min_id → name. Convenience alias for preview table rendering.

JavaScript Functions

FunctionTriggerDescription
goStep(n)Navigation buttonsShows/hides the correct step panel. Updates the step bar active/done states. Calls buildSummary() when navigating to step 3. Scrolls to top.
onBranchChange(sel)Branch selectCalls checkBtn(). Looks up linked ministry via branchMinistry map and auto-fills ministry dropdown if found.
onMinistryChange(sel, fromBranch)Ministry select + onBranchChangeToggles override-active class on select. Updates hint text and shows/hides the rate badge with min_percent from ministryData.
fileSelected(input)File input changeShows selected file name and size in the drop zone. Calls checkBtn().
checkBtn()Branch change + file selectEnables/disables the Preview button based on whether both a branch and file are present.
doPreview()Preview buttonBuilds FormData, transitions to step 2, POSTs to action=preview, calls renderPreview() on success or returns to step 1 on error.
renderPreview(data)After doPreview()Renders stats strip and preview table from data.rows. Enables/disables Confirm button based on data.ok count.
buildSummary(ok)goStep(3)Computes total loan value, reads branch/ministry display names, renders summary grid HTML into #importSummaryDetails.
doImport()Import Now buttonGuards with importDone. Sends ok rows as JSON to action=import. Runs progress bar animation. Calls showResult() on completion.
showResult(type, count, msg)After doImport()Renders success (✓) or error (✗) result panel with count, message, and action buttons.
downloadTemplate()Template buttonCreates a Blob with the CSV header row + one example row and triggers a browser download as loan_import_template.csv.
downloadErrors()Export Errors buttonFilters previewRows for error rows, builds a CSV Blob in memory, and triggers download as import_errors.csv.
fmt(v)render helpersFormats a number as ₦ with Naira locale formatting (en-NG, 2 decimal places).
esc(s)render helpersHTML-escapes a string for safe insertion into innerHTML.
toast(msg, type)VariousAppends a toast notification (success / error / info) to #toastContainer. Auto-removes after 4.5 seconds.

Error Handling

Errors are classified into three categories: row-level validation errors (block import), row-level warnings (non-blocking), and request/server errors (block the entire action).

A Row Validation Errors

ConditionMessageEffect
IPPS column is blankIPPS number missingRow marked status = "error", skipped on import.
IPPS not in customers tableIPPS '{value}' not found in customersRow marked as error. Customer fields show "—".
loan_amount ≤ 0Invalid loan amountRow marked as error.
tenor ≤ 0Invalid tenor (must be > 0)Row marked as error.

B Row Warnings

ConditionMessageEffect
netpay ≤ 0Net pay is zero or missingWarning shown in preview table. Row is still imported with netpay = 0.

C Server / Request Errors

ConditionResponseClient Behaviour
No branch selected{"error": "Please select a branch."}Toast shown, stays on step 2.
No file uploaded{"error": "No file uploaded."}Toast shown, returns to step 1.
Invalid file type{"error": "Only CSV, XLSX, XLS files are allowed."}Toast shown, returns to step 1.
Empty/unparseable file{"error": "File is empty or could not be parsed."}Toast shown, returns to step 1.
XLSX without PhpSpreadsheet{"error": "XLSX support requires PhpSpreadsheet…"}Toast shown, returns to step 1.
Invalid JSON responseParse error caught client-sideToast shown, raw text logged to console, returns to step 1.
PHP fatal error (any)Shutdown function encodes as JSONError message with file + line displayed in toast.
Transaction rollback on import failure The PHP import action wraps all inserts in $conn->begin_transaction(). If a prepare() call fails, the entire transaction is rolled back and an error is returned. Individual row execute() failures are collected in failedRows but do not roll back successful rows already inserted.