Customer Module

Customer
Import Docs

A guided 3-step wizard for bulk-importing customers into Sodlapp, with full branch assignment, CSV/Excel parsing, live validation preview, and row-by-row API submission.

customers.php includes/customer_api.php SheetJS (xlsx@0.18.5)

The Customer Import module provides a clean, sequential interface for bulk-loading customer records into the system. The page is intentionally import-only — no listing, searching, or editing is done here. All create and update operations happen through customer_api.php.

All imported customers are assigned to a branch at the time of import. The branch cannot be changed after import begins without refreshing the page.

Import Workflow

The page follows a strict linear 3-step flow. Steps 2 and 3 are locked until Step 1 is completed. This prevents accidental uploads without a branch assignment.

1
Select Branch
Choose from cards or dropdown
2
Download Template
CSV or Excel with correct headers
3
Upload & Import
Preview rows then confirm import
1
Page loads, branches fetched
On page load, loadBranches() calls ministry_api.php?action=branches. Up to 6 branches appear as clickable tiles; all branches appear in the dropdown below.
2
User selects a branch
Clicking a tile or choosing from the dropdown sets selBranch. The "Confirm Branch & Continue" button is enabled. Tile and dropdown always stay in sync.
3
Branch confirmed, steps 2 & 3 unlock
On confirm, a teal banner replaces the picker showing the branch name and code. Steps 2 and 3 become interactive. A "Change" button lets the user reset.
4
User downloads template (optional)
CSV or Excel template with all 22 column headers is generated client-side and downloaded instantly. Step 2 is informational — it does not block Step 3.
5
File dropped or selected
The file input and drag-drop zone accept .csv, .xlsx, .xls. CSV is parsed natively. Excel files are parsed using SheetJS and converted to CSV internally before parsing.
6
Client-side validation & preview
Every row is validated for required fields and enum values. A preview table shows all rows (up to 150 visible), with error notes per row. Valid/error counts are shown in the preview header bar.
7
Import runs row-by-row
Clicking "Import" sends only valid rows to customer_api.php as individual POST requests. A live progress bar updates after each row. The branch_fk and status: 'Active' are injected automatically.
8
Result card shown
A result card summarises total rows, how many were imported successfully, and how many failed (e.g. duplicate phone/ID). Step 3 is marked done if at least one row succeeded.

Requirements

Server-side dependencies

config.php
Must expose a $pdo PDO instance and a checkLogin() session guard.Required by both customers.php and customer_api.php
Required
session_helper.php
Loaded after config.php for user context (name, role, branch).Used by customers.php
Required
aside.php
Shared sidebar navigation. Must render a #sidebar element and a #layout wrapper.Included via PHP
Required
ministry_api.php
Must handle ?action=branches and return { success, data: [{ branch_id, branch_name, branch_code }] }.Called on page load to populate the branch picker
Required
customer_api.php
Must handle POST requests with a JSON body containing at minimum: fullname, phone, id_type, id_number, form_type, branch_fk, status.Called per row during import
Required

Client-side dependencies

Poppins
Google Fonts. Weights 300, 400, 500, 600, 700, 900 loaded via CDN.font-family: 'Poppins', sans-serif
Required
xlsx@0.18.5
SheetJS loaded from Cloudflare CDN at end of body. Used to parse .xlsx and .xls files client-side.Optional for CSV-only imports
Optional

If SheetJS fails to load (e.g. no internet), Excel file imports will fall back to raw binary text and likely fail silently. Always provide users with the CSV option as a fallback.

Step 1 — Branch Select

On page load, loadBranches() is called automatically. It fetches branches from ministry_api.php?action=branches and renders the first 6 as clickable cards plus all branches in a dropdown.

Branch API expected response

JSON
{
  "success": true,
  "message": "OK",
  "data": [
    {
      "branch_id":   1,
      "branch_name": "Head Office",
      "branch_code": "HQ-001"
    },
    // ... more branches
  ]
}

The page shows only the first 6 branches as card tiles. If you have more branches, they are all accessible in the dropdown list. Both methods set the same internal selBranch state and stay in sync.

State after confirmation

Once the user clicks "Confirm Branch & Continue", the following happens:

  • The branch picker is hidden and replaced with a teal summary banner
  • Steps 2 and 3 are unlocked (the locked CSS class is removed)
  • The branch label in the import action row is updated
  • selBranch.branch_id is stored and will be sent as branch_fk on every import POST

Step 2 — Download Template

This step is informational. It provides downloadable templates (CSV and Excel) with the exact column headers the import parser expects. Filling in these templates ensures columns are mapped correctly.

Templates are generated entirely client-side as a blob download — no server call is made.

Step 2 does not gate Step 3. Users can skip the template download and upload a file directly — useful for users who already have properly formatted data files.

Step 3 — Upload & Import

Accepted file types

FormatExtensionParserMax Rows
.csvcsvNative JS (FileReader + custom CSV parser)5,000
Excel 2007+xlsxSheetJS — converted to CSV internally5,000
Excel 97–2003xlsSheetJS — converted to CSV internally5,000

Client-side validation

Before sending any rows to the API, every row is validated in the browser:

ERR
fullname required — the fullname column is empty or missing
ERR
phone required — the phone column is empty or missing
ERR
id_type must be BVN/NIN — value is missing or not exactly BVN or NIN (auto-uppercased)
ERR
id_number required — the id_number column is empty or missing
ERR
form_type must be PAYROLL/GROUP — value is missing or not exactly PAYROLL or GROUP (auto-uppercased)

Rows with any error are excluded from import but shown in the preview table with a red background and inline error note. The import button shows only the count of valid rows.

Import execution

Each valid row is sent to customer_api.php as a separate POST request. The following fields are always injected regardless of what is in the file:

branch_fk
Set from selBranch.branch_id — the confirmed branch from Step 1.Always injected; file value is ignored
Injected
status
Hardcoded to 'Active' for all imported rows.Always injected; file value is ignored
Injected

Import runs sequentially (one row at a time), not in parallel. This is intentional — it avoids overwhelming the server and provides accurate per-row progress. On slow connections, imports of 500+ rows may take some time.

API Overview

All API endpoints are handled by includes/customer_api.php. Every response follows the same envelope format:

JSON — Response Envelope
{
  "success": true // or false,
  "message": "Human-readable status",
  "data":    {} // payload or null on error
}

List Customers

GET includes/customer_api.php 200 OK

Returns a paginated, filterable list of customers. Soft-deleted rows (status = 'Deleted') are excluded by default.

Query parameters

ParameterTypeDefaultDescription
pageinteger1Page number (1-based)
per_pageinteger15Rows per page (max 500)
searchstringSearches fullname, phone, email, id_number, employer (LIKE %…%)
statusstringFilter by status (Active / Inactive / Deleted)
form_typestringFilter by form type: PAYROLL or GROUP
id_typestringFilter by ID type: BVN or NIN
genderstringFilter by gender: Male or Female
fromdate (YYYY-MM-DD)Filter created_at from date
todate (YYYY-MM-DD)Filter created_at to date
sortstringcreated_atColumn to sort by. Allowed: userid, fullname, phone, email, form_type, id_type, gender, employer, status, created_at
dirstringdescasc or desc
include_deletedanyIf present, includes soft-deleted rows

Get Single Customer

GET includes/customer_api.php?id={userid} 200 OK

Returns a single customer record by primary key.

Response CodeCondition
200Customer found — returns full row in data
404No customer with that userid

Create Customer

POST includes/customer_api.php 201 Created

Creates a new customer. Body must be Content-Type: application/json.

Required fields

fullname
Customer full name (string, non-empty)
Required
phone
Primary phone number. Must be unique across non-deleted records.
Required
id_type
Identification type. Accepted: BVN or NIN. Auto-uppercased.
Required
id_number
Identification number (BVN or NIN). Must be unique across non-deleted records.
Required
form_type
Loan form category. Accepted: PAYROLL or GROUP. Auto-uppercased.
Required

Example request

JavaScript — fetch
const res = await fetch('includes/customer_api.php', {
  method:  'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    fullname:   "Amina Bello",
    phone:      "08012345678",
    id_type:   "BVN",
    id_number: "12345678901",
    form_type: "PAYROLL",
    branch_fk: 3,
    gender:    "Female",
    employer:  "Federal Polytechnic",
    status:    "Active"
  })
});
const json = await res.json();
// json.data.userid — new customer ID

Response codes

201
Customer created. data.userid contains the new primary key; data.customer contains the full row.
400
No valid fields provided (empty body).
409
Duplicate phone number or duplicate ID number in non-deleted records.
422
Validation failure — required field missing or enum value invalid. Message lists all errors.
500
Database error.

Update Customer

PUT includes/customer_api.php?id={userid} 200 OK

Partial update — only the fields included in the body are changed. The body must be Content-Type: application/json. Any field in ALLOWED_COLUMNS can be updated.

Enum validation on PUT is non-strict — required fields (fullname, phone, etc.) are not enforced on update. Only enum values are validated if the field is present in the body.

Response codes

200
Customer updated. data.customer contains the updated full row.
400
Missing id parameter or empty body.
404
No customer with that userid.
409
Another customer (not self) has the same phone or ID number.
422
Invalid enum value (id_type, form_type, gender, or marital_status).

Delete Customer

DELETE includes/customer_api.php?id={userid} 200 OK

By default, delete is a soft-delete — it sets status = 'Deleted' and updated_at = NOW(). The record remains in the database and can be recovered.

To enable hard deletes, set the constant CUSTOMER_HARD_DELETE = true in customer_api.php. This will issue a real SQL DELETE.

Hard deletes cannot be undone. It is strongly recommended to keep CUSTOMER_HARD_DELETE = false in production environments.

Column Reference

All 22 writeable columns. The userid (primary key) and created_at are auto-generated and cannot be set via the import or API.

ColumnTypeRequiredNotes
fullnamestringRequiredCustomer full legal name
phonestringRequiredMust be unique (non-deleted records)
id_typeenumRequiredBVN or NIN (auto-uppercased)
id_numberstringRequiredBVN or NIN digits. Must be unique (non-deleted records)
form_typeenumRequiredPAYROLL or GROUP (auto-uppercased)
emailstringOptionalEmail address
genderenumOptionalMale or Female
marital_statusenumOptionalSingle, Married, or Divorced
dobdateOptionalDate of birth (YYYY-MM-DD recommended)
residential_addressstringOptionalHome/residential address
state_idintegerOptionalFK to states table
city_idintegerOptionalFK to cities table
employerstringOptionalEmployer / company name
employer_addressstringOptionalOffice / employer address
occupationstringOptionalJob title / occupation
ippsstringOptionalIPPS number (for payroll customers)
date_of_employmentdateOptionalEmployment start date
nok_given_namestringOptionalNext of kin full name
nok_phonestringOptionalNext of kin phone
relationshipstringOptionalRelationship to next of kin
bank_namestringOptionalBank name
account_numberstringOptionalBank account number
bank_codestringOptionalBank routing/sort code
balancedecimalOptionalAccount balance
branch_fkintegerOptionalFK to branches table. Always injected during import.
comp_fkintegerOptionalFK to company table
id_tokenstringOptionalToken generated from ID number for lookup
statusstringOptionalDefaults to Active on create. Always set to Active during import.

Validation Rules

Validation happens in two places: client-side in the browser before import, and server-side in customer_api.php. Both layers apply the same rules.

RuleLayerBehaviour on failure
fullname not emptyClient + ServerRow excluded (client) / 422 (server)
phone not emptyClient + ServerRow excluded (client) / 422 (server)
id_type ∈ {BVN, NIN}Client + ServerRow excluded (client) / 422 (server)
id_number not emptyClient + ServerRow excluded (client) / 422 (server)
form_type ∈ {PAYROLL, GROUP}Client + ServerRow excluded (client) / 422 (server)
gender ∈ {Male, Female}Server only422 if gender provided and invalid
marital_status ∈ {Single, Married, Divorced}Server only422 if marital_status provided and invalid
phone uniquenessServer only409 Conflict
id_number uniquenessServer only409 Conflict

Error Reference

HTTP CodesuccessTypical messageCause
200trueOKSuccessful GET
201trueCustomer created successfully.Successful POST create
400falseInvalid JSON payload. / Missing customer ID.Malformed body or missing id param
404falseCustomer not found.No record for that userid
405falseMethod not allowed.HTTP method not supported
409falseA customer with this phone number already exists.Duplicate phone or ID number
422falsefullname is required. id_type must be BVN or NIN.Validation failure — message lists all errors
500falseDatabase error. Please try again.PDO / SQL exception (details in server error log)

Column Mapping

The import parser accepts flexible column header names in uploaded files. These are mapped to canonical field names before validation. The mapping is case-insensitive and spaces are converted to underscores.

Accepted header variantsMaps to
fullname, full_name, name, customer_namefullname
phone, mobile, phone_number, mobile_numberphone
email, email_addressemail
id_number, id_no, bvn, ninid_number
gender, sexgender
marital_status, maritalmarital_status
dob, date_of_birth, birth_datedob
residential_address, address, home_addressresidential_address
employer, company, employer_nameemployer
occupation, job_titleoccupation
date_of_employment, employment_date, start_datedate_of_employment
employer_address, office_addressemployer_address
nok_given_name, nok_name, next_of_kin, noknok_given_name
nok_phone, nok_mobilenok_phone
relationship, nok_relationshiprelationship
bank_name, bankbank_name
account_number, account_no, acct_no, acc_noaccount_number
balance, account_balancebalance

Any column header not found in the mapping table is passed through as-is. If it matches an allowed API field name, it will be included. Unknown columns are silently ignored.