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.
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.
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).
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.
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.
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.
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
| Field | Type | Description |
|---|---|---|
| action | string | Always "preview" |
| branch_id | int | Selected branch ID. Required — returns error if missing. |
| ministry_id | int | Optional global ministry override. 0 if none selected. |
| default_rate | decimal string | Sourced from the selected ministry's min_percent. Falls back to "2". |
| import_file | file | The uploaded CSV, XLSX, or XLS file. |
B Import Request Fields
| Field | Type | Description |
|---|---|---|
| action | string | Always "import" |
| branch_id | int | Same branch selected in Step 1. |
| ministry_id | int | Same ministry override (or 0). |
| rows | JSON string | JSON-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.
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.
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
B Preview Table Columns
| Column | Source | Notes |
|---|---|---|
| # | Row index (1-based) | Displayed as the original file row number. |
| IPPS | r.ipps | Rendered in monospace accent color. |
| Customer | r.customer_name | Full name + phone from matched customer record. Error/warning text appears under this cell. |
| Serial | r.loan_serial | Pre-assigned auto-serial (date + company ID + counter). Purple monospace. |
| Branch | r.branch_id | Resolved via branchNames JS map. |
| Ministry | r.ministry_fk | Resolved via ministryNames JS map. Shows dash if unresolved. |
| Loan Amount | r.loan_amount | Right-aligned, bold. Formatted as ₦ with commas. |
| EMI | r.loan_emi | Monthly installment = (principal + interest) / tenor. |
| Rate | r.loan_pct | Monthly interest rate percentage. Purple, bold. |
| Tenor | r.tenor | Months, e.g. 12m. |
| Type | r.loan_type | New / Top-up / Repeat chip badge. |
| Sector | r.sector | State / Federal / Others chip badge. |
| Date | r.loan_dated | YYYY-MM-DD disbursement date. |
| Status | r.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.
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.
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
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
// 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.
// 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.
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.
| Column | Required | Example | Notes |
|---|---|---|---|
| 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.
| Key | Type | Description |
|---|---|---|
| row | int | 1-based row index from the original file. |
| ipps | string | Raw IPPS value from the CSV row. |
| customer_id | int|null | Matched customer's userid. null if no IPPS match. |
| customer_name | string | Customer's full name. "—" if unmatched. |
| customer_phone | string | Customer phone number (display only). |
| loan_serial | string | Pre-assigned serial number for display. Re-generated at actual import time. |
| loan_amount | float | Parsed principal amount. |
| tenor | int | Repayment months. |
| netpay | float | Monthly net pay. |
| loan_pct | string | Resolved interest rate (string to preserve decimal precision). |
| loan_emi | float | Monthly installment = (principal + interest) / tenor, rounded to 2dp. |
| interest | float | Total interest = principal × (rate/100) × tenor, rounded to 2dp. |
| disbursed_amt | float | Actual disbursed amount. |
| loan_dated | string | Disbursement date as YYYY-MM-DD. |
| loan_type | string | New / Top-up / Repeat. |
| sector | string | State / Federal / Others. |
| ministry_fk | int|null | Resolved ministry ID. null if unresolved. |
| subministry_fk | int|null | From customer's subministry_fk field (if present). |
| state_fk | int|null | Customer's state_id. |
| branch_id | int | Resolved branch ID (per-row override or global selection). |
| errors | array | Array of error message strings. Non-empty → status = "error". |
| warnings | array | Array of warning strings. Non-blocking — row is still imported. |
| status | string | "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'.
| Column | Type | Source |
|---|---|---|
| loan_serial | string | Re-generated at import time from current loan count. |
| comp_fk | string | Session $cp_id — never from the client. |
| customer_fk | int | r['customer_id'] cast to int. |
| netpay | float | r['netpay'] |
| loan_amount | float | r['loan_amount'] |
| tenor | int | r['tenor'] |
| state_fk | int|null | r['state_fk'] — null if missing, not 0. |
| loan_percentage | float | r['loan_pct'] |
| disbursed_amt | float | r['disbursed_amt'] |
| loan_princ | float | Same as loan_amount. |
| loan_interest | float | r['interest'] |
| loan_emi | float | r['loan_emi'] |
| ministry_fk | int|null | Global override takes priority; falls back to r['ministry_fk']. Null if unresolved. |
| subministry_fk | int|null | r['subministry_fk'] — null if missing. |
| loan_branch_fk | int | r['branch_id'] or fallback to POST branch_id. |
| loan_type | string | New / Top-up / Repeat |
| sector | string | State / Federal / Others |
| outstanding | float | loan_amount + loan_interest, rounded to 2dp. |
| loan_status | string | Always 'disbursed' for imported loans. |
| booked_by | string | $_SESSION['username'] or 'import' if session missing. |
| loan_dated | string | Disbursement date YYYY-MM-DD. |
| loan_time | string | Current datetime at import execution. |
| loan_created_at | datetime | NOW() — set by MySQL, not bound. |
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
| Variable | Type | Description |
|---|---|---|
| previewRows | array | Full array of preview row objects from the last action=preview response. Both renderPreview() and buildSummary() read from this. Populated by doPreview(). |
| currentStep | number | Active step number (1, 2, or 3). Updated by goStep(n). |
| importDone | boolean | Set to true once a successful import completes. Guards doImport() against re-execution. |
| branchNames | object | PHP-injected map of branch_id → display name. Used in preview table to show branch labels from IDs. |
| ministryData | object | PHP-injected map of min_id → { name, pct }. Used for rate badge display and summary grid. |
| branchMinistry | object | PHP-injected map of branch_id → ministry_fk. Used by onBranchChange() to auto-fill ministry dropdown. |
| ministryNames | object | Derived from ministryData as min_id → name. Convenience alias for preview table rendering. |
JavaScript Functions
| Function | Trigger | Description |
|---|---|---|
| goStep(n) | Navigation buttons | Shows/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 select | Calls checkBtn(). Looks up linked ministry via branchMinistry map and auto-fills ministry dropdown if found. |
| onMinistryChange(sel, fromBranch) | Ministry select + onBranchChange | Toggles override-active class on select. Updates hint text and shows/hides the rate badge with min_percent from ministryData. |
| fileSelected(input) | File input change | Shows selected file name and size in the drop zone. Calls checkBtn(). |
| checkBtn() | Branch change + file select | Enables/disables the Preview button based on whether both a branch and file are present. |
| doPreview() | Preview button | Builds 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 button | Guards 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 button | Creates a Blob with the CSV header row + one example row and triggers a browser download as loan_import_template.csv. |
| downloadErrors() | Export Errors button | Filters previewRows for error rows, builds a CSV Blob in memory, and triggers download as import_errors.csv. |
| fmt(v) | render helpers | Formats a number as ₦ with Naira locale formatting (en-NG, 2 decimal places). |
| esc(s) | render helpers | HTML-escapes a string for safe insertion into innerHTML. |
| toast(msg, type) | Various | Appends 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
| Condition | Message | Effect |
|---|---|---|
| IPPS column is blank | IPPS number missing | Row marked status = "error", skipped on import. |
| IPPS not in customers table | IPPS '{value}' not found in customers | Row marked as error. Customer fields show "—". |
| loan_amount ≤ 0 | Invalid loan amount | Row marked as error. |
| tenor ≤ 0 | Invalid tenor (must be > 0) | Row marked as error. |
B Row Warnings
| Condition | Message | Effect |
|---|---|---|
| netpay ≤ 0 | Net pay is zero or missing | Warning shown in preview table. Row is still imported with netpay = 0. |
C Server / Request Errors
| Condition | Response | Client 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 response | Parse error caught client-side | Toast shown, raw text logged to console, returns to step 1. |
| PHP fatal error (any) | Shutdown function encodes as JSON | Error message with file + line displayed in toast. |
$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.