/ Docs / Bank Receivables
Finance bank-receivables
Overview Workflow Page Structure API Endpoints Stats Dashboard Entry Listing Pagination Add / Edit Modal View Modal Workflow Modals DB Schema API Payload API Responses JS State Variables Functions Validation Rules Error Handling
Finance · Page Documentation bank-receivables receivables_api

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.

3-Stage Approval Immutable Audit Log Server-side Pagination Credit / Debit Tracking Multi-tenant Isolated

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.

1 Page loads
2 load() fetches page 1
3 Stats + Table + Pagination rendered
4 User filters / searches / pages
5 New load() on every state change

Approval 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.

Draft Posted Approved
or from Posted → Rejected → edit → back to Draft → Post again
From StatusTo StatusActionWhoGuard
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
Approved entries are locked Once an entry reaches approved status it cannot be edited, re-posted, or soft-deleted. Any attempt returns a 409 Conflict response. This protects the financial record's integrity.
Every transition is logged 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 Row
5 KPI cards: Total, Draft, Posted, Approved (with ₦ total), Rejected

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.

Workflow Banner
Visual 4-step flow: Create → Audit Post → Manager Review → Approved

Static informational banner showing the approval pipeline. Hidden at ≤820 px to save vertical space on mobile.

Toolbar
Filter pills (All / Draft / Posted / Approved / Rejected) · Date range · Debounced search

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.

Desktop Table + Mobile Cards
Dual-view rendering from the same viewList array

Table 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.

Pagination Bar
Desktop: smart page range + per-page selector · Mobile: Prev/Next only

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.

Four Modal Overlays
Add/Edit · View (with audit trail) · Post confirm · Approve/Reject confirm

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.

GETapi/receivables_apiPaginated list + stats
GETapi/receivables_api?id=NSingle entry + audit trail
POSTapi/receivables_apiCreate draft entry (201)
PUTapi/receivables_api?id=NUpdate draft/rejected entry
PUTapi/receivables_api?action=post&id=NAudit-post: draft → posted
PUTapi/receivables_api?action=approve&id=NApprove: posted → approved
PUTapi/receivables_api?action=reject&id=NReject: posted → rejected
DELETEapi/receivables_api?id=NSoft-delete (non-approved only)

A GET List — Query Parameters

ParameterTypeDefaultDescription
pageint ≥11Page number to retrieve.
per_pageint 5–10020Rows per page. Clamped server-side between 5 and 100.
statusenumallall | draft | posted | approved | rejected
searchstringSearches uid, title, and narration with LIKE %value%.
date_fromYYYY-MM-DDFilters entry_date >= date_from.
date_toYYYY-MM-DDFilters entry_date <= date_to.
sortstringentry_dateentry_date | created_at | credit_value | debit_value | title | status | uid. Unknown values fall back to entry_date.
orderstringdescasc or desc (case-insensitive).
Stats are always company-wide The 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.

Total
47
All entries
Draft
12
Awaiting post
Posted
8
Pending approval
Approved
24
₦142.6M credited
Rejected
3
Needs correction

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

ColumnSource FieldNotes
IdentifieruidHuman-readable UID (e.g. RCV-2025-XK3F1284). Monospace font, truncated at ~12 chars.
TitletitleEntry title. Truncated with ellipsis on overflow.
NarrationnarrationEntry narration. Max ~40 chars visible in table cell.
Dateentry_dateFormatted via fDate() → "12 Jan 2025".
Credit (₦)credit_valueRight-aligned, formatted via fMoney(). Always green.
Debit (₦)debit_valueRight-aligned, formatted via fMoney().
Netnet_valueComputed server-side as credit_value - debit_value. Green if positive, rust red if negative.
StatusstatusColour-coded chip via statusChip(s). Four colours: slate/draft, gold/posted, emerald/approved, rust/rejected.
ActionsContext-sensitive. All entries show View. Draft/rejected show Edit + Post. Posted shows Approve + Reject.

B Action Buttons per Status

StatusViewEditPostApproveRejectDelete
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().

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 IDTypeRequiredDescription
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.
Editing a rejected entry resets to draft When a user edits and saves a rejected entry, the API automatically resets its status back to draft. This is enforced server-side in handleUpdate() regardless of what the frontend sends.

B UID Generation

genUID() — client-side (for display preview) JavaScript
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.

Post Confirm (#postModal)
Sends PUT ?action=post&id=N · No body required

Available on Draft and Rejected entries. Clicking "Post Entry" calls confirmPost(). On success: toast notification, modal closed, list reloads.

Approve Confirm (#approveModal)
Sends PUT ?action=approve&id=N · No body required

Available only on Posted entries. Calls confirmApprove(). Finalises the entry — it becomes locked after this point.

Reject Confirm (#rejectModal)
Sends PUT ?action=reject&id=N · Body: { reason: "..." }

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.

All workflow buttons use credentials: same-origin Every 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

ColumnTypeNotes
idint PKAuto-increment primary key.
uidvarchar UNIQUEHuman-readable identifier: RCV-{YEAR}-{base36}{rand}. Unique index. Auto-generated if not supplied; server deduplicates on collision.
titlevarcharEntry title. Required.
narrationtextDetailed narration. Required.
entry_datedateAccounting entry date in YYYY-MM-DD format. Used for date-range filtering and default sort.
credit_valuedecimal(18,2)Credit amount. Must be > 0. High precision for large financial figures.
debit_valuedecimal(18,2)Debit amount. Defaults to 0.00. net_value is computed as credit - debit.
statusenum'draft' | 'posted' | 'approved' | 'rejected'. Default: draft.
created_byint FKSession team_id at time of creation.
created_by_namevarcharName snapshot at creation time. Prevents broken references if the team member record is later renamed.
created_atdatetimeAuto-set on INSERT.
posted_byint FKSession team_id who posted. NULL until posted.
posted_by_namevarcharName snapshot at post time. NULL until posted.
posted_atdatetimeTimestamp of audit post. NULL until posted.
approved_byint FKManager's team_id. Set on both approve and reject actions.
approved_by_namevarcharManager's name snapshot. Set on both approve and reject.
approved_atdatetimeTimestamp of manager action (approve or reject). NULL until then.
reject_reasontextRejection feedback from manager. NULL if not rejected. Shown in the view modal trail.
company_fkint FKMulti-tenant isolation key. Set server-side from session — never sent by the client.
is_deletedtinyint(1)Soft-delete flag. Default 0. All queries filter is_deleted = 0. Approved entries cannot be soft-deleted.
updated_atdatetimeAuto-updated on every modification.

B bank_receivables_audit

ColumnTypeNotes
idint PKAuto-increment primary key.
entry_idint FKReferences bank_receivables.id. Indexed.
actionenum'created' | 'edited' | 'posted' | 'approved' | 'rejected' | 'deleted'.
from_statusvarcharStatus before the transition. NULL for created.
to_statusvarcharStatus after the transition. NULL for deleted.
actor_idint FKteam_id of the person who performed the action.
actor_namevarcharName snapshot of the actor at action time.
notetextAdditional context. Used for rejection reason. NULL for all other actions.
created_atdatetimeTimestamp of the audit event. Records are append-only and never modified.
Audit log is write-once 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)

POST api/receivables_api — create draft JSON
{
  "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

PUT ?action=reject&id=N — reject body JSON
{
  "reason": "Credit value does not match bank statement. Please verify figure."
}

D Payload Field Reference

KeyTypeRequiredNotes
uidstringOptionalClient-generated UID. Server regenerates if missing or already exists in DB.
titlestringRequiredTrimmed. Validated server-side. 422 if empty.
narrationstringRequiredTrimmed. 422 if empty.
entry_dateYYYY-MM-DDRequiredValidated against /^\d{4}-\d{2}-\d{2}$/. 422 if missing or wrong format.
credit_valuefloatRequiredCast to (float) server-side. 422 if zero or missing.
debit_valuefloatOptionalCast to (float). Defaults to 0 if not provided.
reasonstringReject onlyRejection reason. Trimmed. 422 if empty on reject action.

API Responses

A List Response

GET api/receivables_api — list response JSON
{
  "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

CodeMeaningWhen returned
200OKAll successful GET, PUT, DELETE responses.
201CreatedSuccessful POST (new entry created).
400Bad Requestid missing on PUT/DELETE.
401UnauthenticatedNo active session / team_id missing.
403Forbiddencompany_id missing from session.
404Not FoundEntry not found, belongs to different company, or is soft-deleted.
405Method Not AllowedUnsupported HTTP method.
409ConflictInvalid workflow transition (e.g. editing an approved entry, approving a draft).
422UnprocessableMissing or invalid required field (title, narration, entry_date, credit_value, reject reason).
500Server ErrorDB connection unavailable, SQL error, or uncaught exception.

JavaScript State Variables

VariableTypeDescription
viewListarrayThe current page of entry rows from the last API response. All render functions read from this. Replaced on every load().
filterStatestringActive status filter. One of: 'all' | 'draft' | 'posted' | 'approved' | 'rejected'. Sent as ?status=. Default 'all'.
searchStrstringCurrent debounced search query. Sent as ?search=. Empty string by default.
dateFromstringValue of the #dateFrom date input. Sent as ?date_from=.
dateTostringValue of the #dateTo date input. Sent as ?date_to=.
editIdnumber|nullnull = Add mode. Integer = Edit mode. Determines POST vs PUT in saveEntry().
actionIdnumber|nullEntry ID queued for a workflow action (post/approve/reject). Set by openPost(), openApprove(), openReject().
sTimertimeoutDebounce timer for the search input. Cleared and reset on every keystroke. Fires load(true) after 300 ms of inactivity.
currentPagenumberCurrent page number. Sent as ?page=. Reset to 1 by load(true).
perPagenumberRows per page. Options: 10/20/50/100. Default 20. Updated by setPerPage().
totalPagesnumberServer-reported pagination.last_page. Used by buildPageRange().
totalRecordsnumberServer-reported pagination.total. Used for the info text and page-bar visibility.
_loadingbooleanGuard flag. Set true at start of load(), false in finally. Prevents concurrent fetch calls on rapid interactions.

Functions

FunctionTriggerDescription
load(resetPage?)Init + filters + CRUDCore 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 buttonsValidates p is within range, sets currentPage, calls load(), scrolls to top.
setPerPage(val)Per-page selectUpdates perPage, resets to page 1, calls load().
setFilter(f, btn)Filter pillsUpdates filterState, moves .active class to clicked button, calls load(true).
debSearch(v)Search inputTrims value into searchStr, clears old timer, sets new 300 ms timer to call load(true).
saveEntry()Form save buttonValidates 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 buttonSets editId=null, generates fresh UID via regenUID(), clears form, opens #formModal.
openEdit(id)Edit buttonSets editId, finds entry in viewList, populates form fields, opens #formModal.
openView(id)View buttonFinds entry in viewList or fetches from API (GET ?id=N). Renders field grid + audit trail, opens #viewModal.
openPost(id)Post buttonSets actionId, renders entry preview in modal, opens #postModal.
openApprove(id)Approve buttonSets actionId, renders entry preview, opens #approveModal.
openReject(id)Reject buttonSets actionId, renders entry preview, clears reason textarea, opens #rejectModal.
confirmPost()Post modal confirmSends PUT ?action=post&id={actionId}. No body. Checks r.ok, shows toast, reloads.
confirmApprove()Approve modal confirmSends PUT ?action=approve&id={actionId}. No body. Checks r.ok, shows toast, reloads.
confirmReject()Reject modal confirmValidates 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() catchShows 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 buttonCalls genUID() and updates the #uidDisplay element.
updateNetPreview()Credit/Debit inputsComputes 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() / modalsHTML-escapes a string via a temporary DOM element. Used everywhere user-provided text is injected into innerHTML.
toast(msg, type?)All CRUD operationsShows a 3-second toast notification. Type: 'ok' (emerald) | 'err' (rust) | 'warn' (gold). Default 'ok'.
closeModal(id)Cancel / backdropRemoves the .open class from the given modal overlay element.
setLoad(btnId, loading)Form / workflow confirmationsDisables 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

OrderCheckFieldBehaviour
#1Title requiredfTitleReturns early if empty after trim. Field highlighted red.
#2Narration requiredfNarrationReturns early if empty after trim.
#3Date requiredfDateReturns early if no date selected.
#4Credit > 0fCreditReturns early if value is 0 or empty. Toast error shown.

B Reject Validation

CheckFieldBehaviour
Reason required #rejectReason Validated in confirmReject() before the API call. Border turns rust red and a toast error fires. The PUT is not sent.
Server-side validation is the source of truth All client validation is UX sugar — the API enforces every rule independently. Any 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)

MechanismPurpose
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 switchCatches any unhandled exception from handler functions (PDO errors, logic bugs, etc.) and converts them to 500 JSON responses via apiAbort().

B Frontend Layer (JavaScript)

MechanismPurpose
if(!r.ok) checkEvery 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 guardPrevents a second load() from firing while one is already in progress (e.g. rapid filter clicks).
Error bannerOn 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 notificationsAll CRUD and workflow operations show a colour-coded toast (emerald/gold/rust) with the server's message on both success and failure.
try/catch/finallyEvery 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.
credentials: same-origin on every request All 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.