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.
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.
loadBranches() calls ministry_api.php?action=branches. Up to 6 branches appear as clickable tiles; all branches appear in the dropdown below.selBranch. The "Confirm Branch & Continue" button is enabled. Tile and dropdown always stay in sync..csv, .xlsx, .xls. CSV is parsed natively. Excel files are parsed using SheetJS and converted to CSV internally before parsing.customer_api.php as individual POST requests. A live progress bar updates after each row. The branch_fk and status: 'Active' are injected automatically.Requirements
Server-side dependencies
$pdo PDO instance and a checkLogin() session guard.Required by both customers.php and customer_api.phpconfig.php for user context (name, role, branch).Used by customers.php#sidebar element and a #layout wrapper.Included via PHP?action=branches and return { success, data: [{ branch_id, branch_name, branch_code }] }.Called on page load to populate the branch pickerPOST requests with a JSON body containing at minimum: fullname, phone, id_type, id_number, form_type, branch_fk, status.Called per row during importClient-side dependencies
.xlsx and .xls files client-side.Optional for CSV-only importsIf 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
{ "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
lockedCSS class is removed) - The branch label in the import action row is updated
selBranch.branch_idis stored and will be sent asbranch_fkon 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
| Format | Extension | Parser | Max Rows |
|---|---|---|---|
| .csv | csv | Native JS (FileReader + custom CSV parser) | 5,000 |
| Excel 2007+ | xlsx | SheetJS — converted to CSV internally | 5,000 |
| Excel 97–2003 | xls | SheetJS — converted to CSV internally | 5,000 |
Client-side validation
Before sending any rows to the API, every row is validated in the browser:
fullname column is empty or missingphone column is empty or missingBVN or NIN (auto-uppercased)id_number column is empty or missingPAYROLL 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:
selBranch.branch_id — the confirmed branch from Step 1.Always injected; file value is ignored'Active' for all imported rows.Always injected; file value is ignoredImport 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:
{ "success": true // or false, "message": "Human-readable status", "data": {} // payload or null on error }
List Customers
Returns a paginated, filterable list of customers. Soft-deleted rows (status = 'Deleted') are excluded by default.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | integer | 1 | Page number (1-based) |
| per_page | integer | 15 | Rows per page (max 500) |
| search | string | — | Searches fullname, phone, email, id_number, employer (LIKE %…%) |
| status | string | — | Filter by status (Active / Inactive / Deleted) |
| form_type | string | — | Filter by form type: PAYROLL or GROUP |
| id_type | string | — | Filter by ID type: BVN or NIN |
| gender | string | — | Filter by gender: Male or Female |
| from | date (YYYY-MM-DD) | — | Filter created_at from date |
| to | date (YYYY-MM-DD) | — | Filter created_at to date |
| sort | string | created_at | Column to sort by. Allowed: userid, fullname, phone, email, form_type, id_type, gender, employer, status, created_at |
| dir | string | desc | asc or desc |
| include_deleted | any | — | If present, includes soft-deleted rows |
Get Single Customer
Returns a single customer record by primary key.
| Response Code | Condition |
|---|---|
| 200 | Customer found — returns full row in data |
| 404 | No customer with that userid |
Create Customer
Creates a new customer. Body must be Content-Type: application/json.
Required fields
Example request
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
data.userid contains the new primary key; data.customer contains the full row.Update Customer
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
data.customer contains the updated full row.id parameter or empty body.userid.Delete Customer
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.
| Column | Type | Required | Notes |
|---|---|---|---|
| fullname | string | Required | Customer full legal name |
| phone | string | Required | Must be unique (non-deleted records) |
| id_type | enum | Required | BVN or NIN (auto-uppercased) |
| id_number | string | Required | BVN or NIN digits. Must be unique (non-deleted records) |
| form_type | enum | Required | PAYROLL or GROUP (auto-uppercased) |
| string | Optional | Email address | |
| gender | enum | Optional | Male or Female |
| marital_status | enum | Optional | Single, Married, or Divorced |
| dob | date | Optional | Date of birth (YYYY-MM-DD recommended) |
| residential_address | string | Optional | Home/residential address |
| state_id | integer | Optional | FK to states table |
| city_id | integer | Optional | FK to cities table |
| employer | string | Optional | Employer / company name |
| employer_address | string | Optional | Office / employer address |
| occupation | string | Optional | Job title / occupation |
| ipps | string | Optional | IPPS number (for payroll customers) |
| date_of_employment | date | Optional | Employment start date |
| nok_given_name | string | Optional | Next of kin full name |
| nok_phone | string | Optional | Next of kin phone |
| relationship | string | Optional | Relationship to next of kin |
| bank_name | string | Optional | Bank name |
| account_number | string | Optional | Bank account number |
| bank_code | string | Optional | Bank routing/sort code |
| balance | decimal | Optional | Account balance |
| branch_fk | integer | Optional | FK to branches table. Always injected during import. |
| comp_fk | integer | Optional | FK to company table |
| id_token | string | Optional | Token generated from ID number for lookup |
| status | string | Optional | Defaults 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.
| Rule | Layer | Behaviour on failure |
|---|---|---|
| fullname not empty | Client + Server | Row excluded (client) / 422 (server) |
| phone not empty | Client + Server | Row excluded (client) / 422 (server) |
| id_type ∈ {BVN, NIN} | Client + Server | Row excluded (client) / 422 (server) |
| id_number not empty | Client + Server | Row excluded (client) / 422 (server) |
| form_type ∈ {PAYROLL, GROUP} | Client + Server | Row excluded (client) / 422 (server) |
| gender ∈ {Male, Female} | Server only | 422 if gender provided and invalid |
| marital_status ∈ {Single, Married, Divorced} | Server only | 422 if marital_status provided and invalid |
| phone uniqueness | Server only | 409 Conflict |
| id_number uniqueness | Server only | 409 Conflict |
Error Reference
| HTTP Code | success | Typical message | Cause |
|---|---|---|---|
| 200 | true | OK | Successful GET |
| 201 | true | Customer created successfully. | Successful POST create |
| 400 | false | Invalid JSON payload. / Missing customer ID. | Malformed body or missing id param |
| 404 | false | Customer not found. | No record for that userid |
| 405 | false | Method not allowed. | HTTP method not supported |
| 409 | false | A customer with this phone number already exists. | Duplicate phone or ID number |
| 422 | false | fullname is required. id_type must be BVN or NIN. | Validation failure — message lists all errors |
| 500 | false | Database 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 variants | Maps to |
|---|---|
| fullname, full_name, name, customer_name | fullname |
| phone, mobile, phone_number, mobile_number | phone |
| email, email_address | |
| id_number, id_no, bvn, nin | id_number |
| gender, sex | gender |
| marital_status, marital | marital_status |
| dob, date_of_birth, birth_date | dob |
| residential_address, address, home_address | residential_address |
| employer, company, employer_name | employer |
| occupation, job_title | occupation |
| date_of_employment, employment_date, start_date | date_of_employment |
| employer_address, office_address | employer_address |
| nok_given_name, nok_name, next_of_kin, nok | nok_given_name |
| nok_phone, nok_mobile | nok_phone |
| relationship, nok_relationship | relationship |
| bank_name, bank | bank_name |
| account_number, account_no, acct_no, acc_no | account_number |
| balance, account_balance | balance |
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.