Bank Receivables
Complete reference for the bank receivables management system — covering the three-stage approval workflow, server-side paginated listing, dual-view table/cards layout, audit trail, and all receivables_api endpoint contracts.
What This System Does
The Bank Receivables module provides a controlled, auditable interface for recording and approving bank receivable entries. Each entry captures a title, narration, entry date, credit value, and optional debit value. Entries move through a mandatory three-stage approval pipeline before being considered final.
The frontend page (bank-receivables) fetches all data from receivables_api via standard fetch() calls. All filtering, sorting, and pagination are performed server-side in MySQL — the client only holds the current page of results. A parallel immutable audit table (bank_receivables_audit) records every status change permanently.
load() fetches page 1load() on every state changeApproval Workflow
Every receivable entry follows a strict four-state lifecycle enforced at the API level. Status transitions are guarded — the server rejects any out-of-order move and records each valid transition in the audit log.
| From Status | To Status | Action | Who | Guard |
|---|---|---|---|---|
| — | Draft | Create new entry | Any authenticated user | Session required |
| Draft | Posted | PUT ?action=post |
Auditor / Staff | Status must be draft or rejected |
| Rejected | Draft | PUT ?id=N (edit) |
Any authenticated user | Status must be draft or rejected |
| Posted | Approved | PUT ?action=approve |
Manager | Status must be posted |
| Posted | Rejected | PUT ?action=reject |
Manager | Status must be posted; reason required |
409 Conflict response. This protects the financial record's integrity.logAudit() is called after every successful state change and writes an immutable row to bank_receivables_audit. The audit log is append-only — it is never updated or deleted, even when an entry is soft-deleted.Page Structure
The page is built from six logical sections inside the main content area, plus four modal overlays that sit at the end of the document outside the layout flow.
Stats come from the stats key in the API list response — a single extra aggregate query run server-side on every list fetch. They always reflect the full company totals regardless of the active filter.
Static informational banner showing the approval pipeline. Hidden at ≤820 px to save vertical space on mobile.
Every toolbar interaction resets currentPage to 1 and calls load(). Search is debounced 300 ms. Filter pill counts are updated from the stats response on each load.
viewList arrayTable columns: Identifier · Title · Narration · Date · Credit ₦ · Debit ₦ · Net · Status · Actions. Table is hidden ≤820 px; card layout shown. Both are always rendered together from the same data.
The pagination bar is shown only when totalRecords > 0. A separate mobile bar is shown ≤820 px with simplified controls. See the Pagination section for full details.
All modals share the same .m-overlay CSS pattern. closeModal(id) handles dismissal. The Post, Approve, and Reject modals show an entry preview before the action is confirmed.
API Endpoints
All requests go to receivables_api. Every response uses the standard envelope {"success": bool, "message": "...", "data": ...}. The API always returns JSON — PHP errors are caught by the global exception handler and converted to JSON 500 responses.
A GET List — Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | int ≥1 | 1 | Page number to retrieve. |
| per_page | int 5–100 | 20 | Rows per page. Clamped server-side between 5 and 100. |
| status | enum | all | all | draft | posted | approved | rejected |
| search | string | — | Searches uid, title, and narration with LIKE %value%. |
| date_from | YYYY-MM-DD | — | Filters entry_date >= date_from. |
| date_to | YYYY-MM-DD | — | Filters entry_date <= date_to. |
| sort | string | entry_date | entry_date | created_at | credit_value | debit_value | title | status | uid. Unknown values fall back to entry_date. |
| order | string | desc | asc or desc (case-insensitive). |
stats object in the list response reflects all non-deleted entries for the company, regardless of the status / search / date filters currently active. This keeps the stat card counts stable while filtering.Stats Dashboard
Five KPI cards are displayed above the toolbar. Their values come from the stats key of every list API response. The renderStats() function updates them after each load() call — no separate API request is made.
The Approved card additionally shows the total credit value of all approved entries via stats.approved_credit_total, formatted with fMoneyShort() (e.g. ₦142.6M credited). The filter pill counts in the toolbar are also updated from the same stats object on every load.
Entry Listing
The listing renders from the current page of results (viewList). Identical data is written into both the desktop HTML table and the mobile card container on every render() call.
A Table Columns
| Column | Source Field | Notes |
|---|---|---|
| Identifier | uid | Human-readable UID (e.g. RCV-2025-XK3F1284). Monospace font, truncated at ~12 chars. |
| Title | title | Entry title. Truncated with ellipsis on overflow. |
| Narration | narration | Entry narration. Max ~40 chars visible in table cell. |
| Date | entry_date | Formatted via fDate() → "12 Jan 2025". |
| Credit (₦) | credit_value | Right-aligned, formatted via fMoney(). Always green. |
| Debit (₦) | debit_value | Right-aligned, formatted via fMoney(). |
| Net | net_value | Computed server-side as credit_value - debit_value. Green if positive, rust red if negative. |
| Status | status | Colour-coded chip via statusChip(s). Four colours: slate/draft, gold/posted, emerald/approved, rust/rejected. |
| Actions | — | Context-sensitive. All entries show View. Draft/rejected show Edit + Post. Posted shows Approve + Reject. |
B Action Buttons per Status
| Status | View | Edit | Post | Approve | Reject | Delete |
|---|---|---|---|---|---|---|
| Draft | ✓ | ✓ | ✓ | — | — | ✓ |
| Posted | ✓ | — | — | ✓ | ✓ | ✓ |
| Approved | ✓ | — | — | — | — | — |
| Rejected | ✓ | ✓ | ✓ | — | — | ✓ |
C Skeleton + Empty + Error States
While a fetch is in progress, showSkeleton() replaces the table body with three shimmer rows using the .sk animation class. A _loading flag prevents overlapping fetches. If the response is empty the table renders a centred empty-state message. If the fetch fails, a dismissible error banner appears above the table with a Retry button wired to load().
Server-side Pagination
All pagination is performed in SQL at the API level. The client only receives the current page of rows. On every filter, search, or page change the full load() cycle runs with the appropriate page and per_page parameters.
A Smart Page Range
The desktop pagination bar renders a smart page-number range with ellipsis via buildPageRange(). It always shows the first page, the last page, the current page, and one page either side of the current — with … gaps where the sequence is non-contiguous.
// 20 pages, current = 1 → [1, 2, '...', 20] // 20 pages, current = 10 → [1, '...', 9, 10, 11, '...', 20] // 20 pages, current = 20 → [1, '...', 19, 20] // 5 pages, current = 3 → [1, 2, 3, 4, 5] (no ellipsis needed)
B Pagination State Variables
| Variable | Set by | Description |
|---|---|---|
| currentPage | API response · goPage() | Current page number. Reset to 1 on filter/search changes. |
| perPage | setPerPage() | Rows per page. Options: 10/20/50/100. Default 20. Persists across filter changes. |
| totalPages | API response | pagination.last_page from server. Used by buildPageRange(). |
| totalRecords | API response | pagination.total. Used for the "Showing X–Y of Z entries" info text. |
C Pagination API Response Keys
{
"total": 150, // total matching rows (not just this page)
"per_page": 20,
"current_page": 3,
"last_page": 8,
"from": 41, // 1-based row number of first result on this page
"to": 60, // 1-based row number of last result on this page
"has_prev": true,
"has_next": true
}
Add / Edit Modal
The same #formModal is reused for both create and update. The modal title ("New Entry" vs "Edit Entry") and the editId variable (null for add, integer for edit) distinguish the mode. Saving calls saveEntry() which POSTs or PUTs based on editId.
A Form Fields
| Field ID | Type | Required | Description |
|---|---|---|---|
| uidDisplay | Auto | Shows the auto-generated UID (RCV-{YEAR}-{base36}-{rand}). A regen button calls regenUID() to generate a new one. Maps to uid. |
|
| fTitle | text | Required | Entry title (e.g. "IPPIS Collections March"). Maps to title. |
| fNarration | textarea | Required | Detailed narration / description. Maps to narration. |
| fDate | date | Required | Entry date in YYYY-MM-DD format. Validated server-side with regex. Maps to entry_date. |
| fCredit | number | Required | Credit value. Must be > 0 (enforced server-side). Triggers net preview update. Maps to credit_value. |
| fDebit | number | Optional | Debit value. Defaults to 0. Triggers net preview update. Maps to debit_value. |
| netPreview | Computed | Live net value preview: credit − debit, updated by updateNetPreview() on every input event. Green if positive, rust red if negative or zero. |
draft. This is enforced server-side in handleUpdate() regardless of what the frontend sends.B UID Generation
function genUID() { const yr = new Date().getFullYear(); const ts = Date.now().toString(36).toUpperCase().slice(-4); const rnd = String(Math.floor(Math.random() * 9000) + 1000); return `RCV-${yr}-${ts}${rnd}`; }
The server independently regenerates the UID if the client-provided one already exists (generateUID() in PHP). The client-side function is for display preview only — the authoritative UID is always confirmed by the server's 201 response.
View Modal
Opens when the eye icon is clicked. If the entry is in viewList (current page) it renders immediately from the in-memory array — no API call. If for any reason it isn't found locally (e.g. navigating directly), openView() falls back to a GET ?id=N request which also returns the full audit trail in the trail array.
The approval trail renders all rows from bank_receivables_audit for this entry in chronological order. Each trail item shows the action type (created / edited / posted / approved / rejected / deleted), the actor's name, and the timestamp. For rejections the rejection reason is shown beneath the meta line.
Workflow Confirm Modals
Three separate confirm dialogs handle the workflow transitions. Each shows a preview of the target entry and a confirmation message before the action is sent to the API.
Available on Draft and Rejected entries. Clicking "Post Entry" calls confirmPost(). On success: toast notification, modal closed, list reloads.
Available only on Posted entries. Calls confirmApprove(). Finalises the entry — it becomes locked after this point.
Available only on Posted entries. Has a required <textarea> for the rejection reason. Validated client-side before the request is sent. The reason is stored in reject_reason and recorded in the audit log.
fetch() call in workflow confirmations includes credentials: 'same-origin' so the PHP session cookie is sent. Without this, the API returns 401 Unauthenticated.Database Schema
Two tables power the system: the main entries table and the immutable audit log.
A bank_receivables
| Column | Type | Notes |
|---|---|---|
| id | int PK | Auto-increment primary key. |
| uid | varchar UNIQUE | Human-readable identifier: RCV-{YEAR}-{base36}{rand}. Unique index. Auto-generated if not supplied; server deduplicates on collision. |
| title | varchar | Entry title. Required. |
| narration | text | Detailed narration. Required. |
| entry_date | date | Accounting entry date in YYYY-MM-DD format. Used for date-range filtering and default sort. |
| credit_value | decimal(18,2) | Credit amount. Must be > 0. High precision for large financial figures. |
| debit_value | decimal(18,2) | Debit amount. Defaults to 0.00. net_value is computed as credit - debit. |
| status | enum | 'draft' | 'posted' | 'approved' | 'rejected'. Default: draft. |
| created_by | int FK | Session team_id at time of creation. |
| created_by_name | varchar | Name snapshot at creation time. Prevents broken references if the team member record is later renamed. |
| created_at | datetime | Auto-set on INSERT. |
| posted_by | int FK | Session team_id who posted. NULL until posted. |
| posted_by_name | varchar | Name snapshot at post time. NULL until posted. |
| posted_at | datetime | Timestamp of audit post. NULL until posted. |
| approved_by | int FK | Manager's team_id. Set on both approve and reject actions. |
| approved_by_name | varchar | Manager's name snapshot. Set on both approve and reject. |
| approved_at | datetime | Timestamp of manager action (approve or reject). NULL until then. |
| reject_reason | text | Rejection feedback from manager. NULL if not rejected. Shown in the view modal trail. |
| company_fk | int FK | Multi-tenant isolation key. Set server-side from session — never sent by the client. |
| is_deleted | tinyint(1) | Soft-delete flag. Default 0. All queries filter is_deleted = 0. Approved entries cannot be soft-deleted. |
| updated_at | datetime | Auto-updated on every modification. |
B bank_receivables_audit
| Column | Type | Notes |
|---|---|---|
| id | int PK | Auto-increment primary key. |
| entry_id | int FK | References bank_receivables.id. Indexed. |
| action | enum | 'created' | 'edited' | 'posted' | 'approved' | 'rejected' | 'deleted'. |
| from_status | varchar | Status before the transition. NULL for created. |
| to_status | varchar | Status after the transition. NULL for deleted. |
| actor_id | int FK | team_id of the person who performed the action. |
| actor_name | varchar | Name snapshot of the actor at action time. |
| note | text | Additional context. Used for rejection reason. NULL for all other actions. |
| created_at | datetime | Timestamp of the audit event. Records are append-only and never modified. |
logAudit() only performs INSERT — there is no UPDATE or DELETE path in the audit table. Even when a main entry is soft-deleted, its audit trail remains intact and queryable. Audit failures are swallowed silently to avoid blocking the main workflow response.API Payload
A Create Entry (POST)
{
"uid": "RCV-2025-XK3F1284", // optional — server regenerates on collision
"title": "IPPIS Collections March",
"narration": "Bank statement matching entry...",
"entry_date": "2025-03-15", // YYYY-MM-DD required
"credit_value": 4250000.00, // must be > 0
"debit_value": 320000.00 // optional, defaults to 0
// company_fk + created_by + status set server-side — never sent by client
}
B Update Entry (PUT)
Identical body to POST. URL must include ?id=N. Only draft and rejected entries can be updated — any other status returns 409 Conflict.
C Reject Entry
{
"reason": "Credit value does not match bank statement. Please verify figure."
}
D Payload Field Reference
| Key | Type | Required | Notes |
|---|---|---|---|
| uid | string | Optional | Client-generated UID. Server regenerates if missing or already exists in DB. |
| title | string | Required | Trimmed. Validated server-side. 422 if empty. |
| narration | string | Required | Trimmed. 422 if empty. |
| entry_date | YYYY-MM-DD | Required | Validated against /^\d{4}-\d{2}-\d{2}$/. 422 if missing or wrong format. |
| credit_value | float | Required | Cast to (float) server-side. 422 if zero or missing. |
| debit_value | float | Optional | Cast to (float). Defaults to 0 if not provided. |
| reason | string | Reject only | Rejection reason. Trimmed. 422 if empty on reject action. |
API Responses
A List Response
{
"success": true,
"data": [
{
"id": 42, "uid": "RCV-2025-XK3F1284", "title": "IPPIS Collections March",
"narration": "...", "entry_date": "2025-03-15",
"credit_value": "4250000.00", "debit_value": "320000.00",
"net_value": "3930000.00", // computed in SQL
"status": "draft",
"created_by": 7, "created_by_name": "Amaka Okonkwo", "created_at": "2025-03-15 09:12:00",
"posted_by": null, "posted_by_name": null, "posted_at": null,
"approved_by": null, "approved_by_name": null, "approved_at": null,
"reject_reason": null, "updated_at": "2025-03-15 09:12:00"
}
],
"pagination": { "total": 150, "per_page": 20, "current_page": 1,
"last_page": 8, "from": 1, "to": 20, "has_prev": false, "has_next": true },
"stats": { "total": 47, "draft": 12, "posted": 8, "approved": 24,
"rejected": 3, "approved_credit_total": "142600000.00" }
}
B HTTP Status Codes
| Code | Meaning | When returned |
|---|---|---|
| 200 | OK | All successful GET, PUT, DELETE responses. |
| 201 | Created | Successful POST (new entry created). |
| 400 | Bad Request | id missing on PUT/DELETE. |
| 401 | Unauthenticated | No active session / team_id missing. |
| 403 | Forbidden | company_id missing from session. |
| 404 | Not Found | Entry not found, belongs to different company, or is soft-deleted. |
| 405 | Method Not Allowed | Unsupported HTTP method. |
| 409 | Conflict | Invalid workflow transition (e.g. editing an approved entry, approving a draft). |
| 422 | Unprocessable | Missing or invalid required field (title, narration, entry_date, credit_value, reject reason). |
| 500 | Server Error | DB connection unavailable, SQL error, or uncaught exception. |
JavaScript State Variables
| Variable | Type | Description |
|---|---|---|
| viewList | array | The current page of entry rows from the last API response. All render functions read from this. Replaced on every load(). |
| filterState | string | Active status filter. One of: 'all' | 'draft' | 'posted' | 'approved' | 'rejected'. Sent as ?status=. Default 'all'. |
| searchStr | string | Current debounced search query. Sent as ?search=. Empty string by default. |
| dateFrom | string | Value of the #dateFrom date input. Sent as ?date_from=. |
| dateTo | string | Value of the #dateTo date input. Sent as ?date_to=. |
| editId | number|null | null = Add mode. Integer = Edit mode. Determines POST vs PUT in saveEntry(). |
| actionId | number|null | Entry ID queued for a workflow action (post/approve/reject). Set by openPost(), openApprove(), openReject(). |
| sTimer | timeout | Debounce timer for the search input. Cleared and reset on every keystroke. Fires load(true) after 300 ms of inactivity. |
| currentPage | number | Current page number. Sent as ?page=. Reset to 1 by load(true). |
| perPage | number | Rows per page. Options: 10/20/50/100. Default 20. Updated by setPerPage(). |
| totalPages | number | Server-reported pagination.last_page. Used by buildPageRange(). |
| totalRecords | number | Server-reported pagination.total. Used for the info text and page-bar visibility. |
| _loading | boolean | Guard flag. Set true at start of load(), false in finally. Prevents concurrent fetch calls on rapid interactions. |
Functions
| Function | Trigger | Description |
|---|---|---|
| load(resetPage?) | Init + filters + CRUD | Core data fetch. Builds URLSearchParams, shows skeleton, fetches API, updates viewList + pagination state, calls renderStats(), render(), renderPagination(). If resetPage=true, sets currentPage=1 first. |
| render() | After load() | Builds desktop table HTML and mobile card HTML from viewList. Handles empty state. Calls statusChip() and actionBtns() for each row. |
| renderStats(st, pg) | After load() | Updates the 5 stat card values and the 5 filter pill counts from the stats API object. |
| renderPagination(pg) | After load() | Shows/hides page bar. Updates "Showing X–Y of Z" info text. Builds page-number buttons via buildPageRange(). Disables Prev/Next at boundaries. |
| buildPageRange(cur, last) | renderPagination() | Returns an array of page numbers and '...' strings for smart ellipsis rendering. Always includes first, last, current ±1. |
| goPage(p) | Pagination buttons | Validates p is within range, sets currentPage, calls load(), scrolls to top. |
| setPerPage(val) | Per-page select | Updates perPage, resets to page 1, calls load(). |
| setFilter(f, btn) | Filter pills | Updates filterState, moves .active class to clicked button, calls load(true). |
| debSearch(v) | Search input | Trims value into searchStr, clears old timer, sets new 300 ms timer to call load(true). |
| saveEntry() | Form save button | Validates required fields, builds payload (no company_fk/status), POSTs or PUTs based on editId. Checks r.ok before parsing JSON. Shows toast on success/error, closes modal, reloads. |
| openAdd() | Add Entry button | Sets editId=null, generates fresh UID via regenUID(), clears form, opens #formModal. |
| openEdit(id) | Edit button | Sets editId, finds entry in viewList, populates form fields, opens #formModal. |
| openView(id) | View button | Finds entry in viewList or fetches from API (GET ?id=N). Renders field grid + audit trail, opens #viewModal. |
| openPost(id) | Post button | Sets actionId, renders entry preview in modal, opens #postModal. |
| openApprove(id) | Approve button | Sets actionId, renders entry preview, opens #approveModal. |
| openReject(id) | Reject button | Sets actionId, renders entry preview, clears reason textarea, opens #rejectModal. |
| confirmPost() | Post modal confirm | Sends PUT ?action=post&id={actionId}. No body. Checks r.ok, shows toast, reloads. |
| confirmApprove() | Approve modal confirm | Sends PUT ?action=approve&id={actionId}. No body. Checks r.ok, shows toast, reloads. |
| confirmReject() | Reject modal confirm | Validates reason, sends PUT ?action=reject&id={actionId} with {"reason":"..."} body. Checks r.ok, shows toast, reloads. |
| showSkeleton() | Start of load() | Replaces table body with 3 shimmer rows immediately when a fetch starts. |
| showLoadError(msg) | load() catch | Shows the rust error banner with the message and a Retry button. |
| hideLoadError() | Successful load() | Hides the error banner after a successful fetch. |
| genUID() | openAdd() / regenUID() | Generates a preview UID in JS. Format mirrors the PHP generateUID() function. |
| regenUID() | Regen button | Calls genUID() and updates the #uidDisplay element. |
| updateNetPreview() | Credit/Debit inputs | Computes credit - debit and updates the net preview element. Green if ≥0, rust if negative. |
| statusChip(s) | render() | Returns HTML for a coloured status chip from a status string. |
| actionBtns(e) | render() | Returns HTML for the action button group based on entry status. |
| fMoney(n) | render() | Formats a number to ₦1,234,567.89 using toLocaleString('en-NG'). |
| fMoneyShort(n) | renderStats() | Compact money format: ₦1.23B / ₦142.6M / ₦14.5K. Used in stat card sub-lines. |
| fDate(d) | render() | Formats date to "15 Mar 2025" via toLocaleDateString('en-GB'). Returns '—' for null. |
| fDT(d) | openView() | Formats datetime to "15 Mar 2025, 09:12". Used in audit trail timestamps. |
| esc(s) | render() / modals | HTML-escapes a string via a temporary DOM element. Used everywhere user-provided text is injected into innerHTML. |
| toast(msg, type?) | All CRUD operations | Shows a 3-second toast notification. Type: 'ok' (emerald) | 'err' (rust) | 'warn' (gold). Default 'ok'. |
| closeModal(id) | Cancel / backdrop | Removes the .open class from the given modal overlay element. |
| setLoad(btnId, loading) | Form / workflow confirmations | Disables button and shows spinner while a request is in flight. Re-enables on complete. |
Validation Rules
Client-side validation runs inside saveEntry() before the API call. The server independently re-validates all fields and returns 422 if any are missing or malformed.
A Form Validation
| Order | Check | Field | Behaviour |
|---|---|---|---|
| #1 | Title required | fTitle | Returns early if empty after trim. Field highlighted red. |
| #2 | Narration required | fNarration | Returns early if empty after trim. |
| #3 | Date required | fDate | Returns early if no date selected. |
| #4 | Credit > 0 | fCredit | Returns early if value is 0 or empty. Toast error shown. |
B Reject Validation
| Check | Field | Behaviour |
|---|---|---|
| Reason required | #rejectReason |
Validated in confirmReject() before the API call. Border turns rust red and a toast error fires. The PUT is not sent. |
success: false JSON response surfaces as an error toast regardless of whether client validation caught the issue first.Error Handling
Both the PHP API and the JavaScript frontend have layered error handling to ensure failures are always communicated clearly, never silently swallowed, and never corrupt the UI state.
A API Layer (PHP)
| Mechanism | Purpose |
|---|---|
ob_start() | Output buffering from line 1. Any PHP notice/warning printed before headers are sent is captured in the buffer, not sent to the browser, so it can't corrupt the JSON response. |
ini_set('display_errors','0') | Prevents PHP from printing HTML <br /><b>Notice</b> fragments into the response. Errors still go to the server error log. |
set_error_handler() | Converts all PHP errors into ErrorException, which the exception handler then catches and returns as JSON. |
set_exception_handler() | Global last-resort handler. Calls ob_clean(), sets Content-Type: application/json, and returns {"success":false,"message":"..."}. Guarantees every failure produces parseable JSON. |
ob_clean() in apiAbort() | Discards any partial output (e.g. something went wrong) before writing the JSON error response. |
| try/catch around route switch | Catches any unhandled exception from handler functions (PDO errors, logic bugs, etc.) and converts them to 500 JSON responses via apiAbort(). |
B Frontend Layer (JavaScript)
| Mechanism | Purpose |
|---|---|
if(!r.ok) check | Every fetch() call checks the HTTP status before r.json(). HTTP 4xx/5xx errors are surfaced with the server's message field in the error toast. |
_loading guard | Prevents a second load() from firing while one is already in progress (e.g. rapid filter clicks). |
| Error banner | On load() failure, a persistent rust banner appears above the table with the error message and a Retry button. It auto-hides on the next successful load. |
| Toast notifications | All CRUD and workflow operations show a colour-coded toast (emerald/gold/rust) with the server's message on both success and failure. |
try/catch/finally | Every async fetch is wrapped in try/catch/finally. The finally block always resets _loading and re-enables buttons, preventing the UI from getting stuck in a loading state. |
fetch() calls include credentials: 'same-origin' to ensure the PHP session cookie is always sent. Without this flag the API returns 401 Unauthenticated even for logged-in users.