Chapter 26: Sacco¶
Book 6 — HR & Payroll. Static-analysis-derived. Bizwiz ERP (multi-tenant Laravel monolith at
/Users/melaniefayne/Desktop/retail-pay/bizwiz). All line references arefile:lineagainst that tree.
1. Purpose¶
The Sacco module is a staff savings-and-credit cooperative run inside the ERP for a tenant's own employees. Employees ("members") build up a share balance through small monthly contributions taken off their pay, and against those shares (or against their salary, or a supplier-set limit) they can borrow. A loan officer captures an application, it works its way through an approval chain, the money is handed out ("disbursed"), and it is then paid back automatically every month as a payroll deduction. If a member stops paying, the loan becomes non-performing and the Sacco can recover what it is owed by seizing the member's own shares, calling on guarantors' shares, and then continuing to grab a slice of payroll until the debt is cleared. When a loan is fully paid it is archived; when a defaulted loan is fully clawed back it is recovered.
So this is a small self-contained lending system: members → shares → loan products → application (draft) → approval → disbursement → monthly payroll repayment → completion / non-performing / recovery / archival, plus a top-up path that lets a member borrow more on top of a running loan.
The module is a modern (2025–2026) rewrite living almost entirely under the sacco_* table family and a dedicated service layer. Two legacy tables — wa_sacco and wa_payroll_sacco (migrations 2023_09_08_134414_create_wa_sacco_table.php, ..._create_wa_payroll_sacco_table.php) — predate this system and are not part of the current flow; the live system uses sacco_loans, sacco_share_transactions, sacco_loan_products, etc.
Everything hangs off HrSaccoController (app/Http/Controllers/Admin/HrSaccoController.php, ~6,290 lines) plus two draft controllers and a family of services (SaccoLoanService, LoanRecoveryService, NplDetectionService, SaccoLoanGateEvaluator, SaccoLoanMergeService, SaccoLoanNotificationService). Routes live in routes/modules/hr.php:553-726 under the admin/hr/sacco prefix.
The design deliberately keeps Sacco's operational accounting (who owes what, whose shares are where) in its own sacco_* tables, and defers all financial accounting (the double-entry general-ledger impact and supplier remittance) to the payroll posting pipeline. That split is the single most important thing to understand about the module: Sacco is a lending front-office that leans on payroll for both collection and posting. A member never touches these screens; a Sacco/HR officer drives the whole lifecycle on their behalf, and the money movement rides on the monthly payroll cycle rather than on any real-time payment rail.
2. Users & roles¶
Sacco is an admin/HR back-office module — members do not self-serve here; HR/Sacco staff act on their behalf. Access is gated by named permissions, and the loan-approval steps are gated by approver permissions (a maker-checker design: the person who captures a loan is not the one who approves it).
The permission keys (from the sidebar resources/views/admin/includes/sidebar_includes/hr.blade.php) are:
| Permission key | Guards |
|---|---|
hr-and-sacco___view |
whole HR/Sacco area |
sacco-dashboard___view |
Sacco dashboard (hr.sacco.index) |
sacco-shares___view |
Sacco Shares screen |
sacco-loans___list |
list loans |
sacco-loans___view |
view a loan / NPL / recovered |
sacco-loans___create |
capture drafts / loans |
sacco-loans___branch_approve |
branch-level approval |
sacco-loans___hq_approve |
HQ-level approval |
sacco-loans___management_approve |
management-level approval |
sacco-loans___archive |
archive loans |
role_id == 1 (super admin) bypasses every check in the blade.
The three approval levels are enforced in the controller with can('<action>', 'sacco-loans') helper calls, e.g. HrSaccoController::approveLoan():
if ($level === 'branch' && !can('branch_approve', 'sacco-loans')) { ... }
if ($level === 'hq' && !can('hq_approve', 'sacco-loans')) { ... }
if ($level === 'management' && !can('management_approve', 'sacco-loans')) { ... }
app/Http/Controllers/Admin/HrSaccoController.php, approveLoan())
For a single-approval product any one of the three approval permissions is sufficient. This is the "approvers, not curators" surface: approval permissions live on the reviewer, not on the loan record. The approval queues themselves are served by dedicated datatables per level — approvals.datatable.single|branch|hq|management (routes/modules/hr.php:635-638) and the mirrored topup-approvals.datatable.* set. Note the mismatch: the model stores approvals as branch_approved_*, hq_hr_approved_*, hq_gm_approved_* (three columns), while the UI/permissions talk in branch / hq / management terms — HQ maps to hq_hr, management maps to hq_gm.
The multi-level chain is also ordered, not just multi-signature: approveLoan() throws if HQ approval is attempted before branch approval, and re-approval at an already-stamped level is rejected. SaccoLoan exposes scope-style helpers (pendingBranchApproval = branch not yet stamped; pendingHqApproval = branch done, HQ pending; pendingManagementApproval = HQ done, GM pending) that feed the per-level datatables, so a loan surfaces in exactly one queue at a time as it climbs. Rejection at any level short-circuits to status='rejected' and records the rejecting user and reason (fields from migration 2025_08_14_075505). There is no separate "curator" permission for the maker step — capture is gated by sacco-loans___create, and the maker-checker separation is enforced purely by which permissions a user holds.
3. Processes¶
3.1 Loan lifecycle (application → approval → disbursement → recovery)¶
flowchart TD
A[Draft captured<br/>SaccoLoanDraft status=draft] -->|evaluate via<br/>SaccoLoanGateEvaluator| B{Viable?}
B -->|submit| C[SaccoLoan created<br/>status=pending]
C --> D{Product approval_type}
D -->|single| E[One approval<br/>hq_hr_approved_at set<br/>status=approved]
D -->|multi_level| F[Branch approve<br/>branch_approved_at]
F --> G[HQ approve<br/>hq_hr_approved_at]
G --> H[Management approve<br/>hq_gm_approved_at<br/>status=approved]
C -.reject.-> R[status=rejected]
E --> I[disburseLoan<br/>status=disbursed<br/>+ disbursement txn]
H --> I
I --> J[Monthly payroll deduction<br/>DED-SACCO-LOAN / -INTEREST]
J --> K{Fully repaid?}
K -->|yes| L[status=completed<br/>Archived]
K -->|missed payments| M[NPL detected<br/>NplDetectionService]
M --> N[initiateRecovery<br/>seize borrower shares →<br/>guarantor shares → payroll]
N --> O[Recovery obligations<br/>deducted via payroll]
O --> P[status=completed / Recovered]
Plain-language walk-through:
- Draft. A Sacco officer captures a loan application as a
SaccoLoanDraft(statusdraft). The draft can be evaluated against product rules bySaccoLoanGateEvaluatorbefore it becomes a real loan (HrSaccoLoanDraftController::evaluate). Drafts can be edited, discarded, or submitted; on submit a realSaccoLoanis created (status='pending') and the draft recordssubmitted_loan_id. - Approval. Depending on the product's
approval_type, either one approval (single) or a three-step chain (multi_level: branch → HQ → management) is required. Each step stamps*_approved_by/*_approved_at. A loan can also be rejected (status='rejected', with rejection fields added in migration2025_08_14_075505). - Disbursement. Once
isFullyApproved()returns true,disburseLoan()flips status todisbursed, stampsdisbursed_at, and writes adisbursementrow intosacco_loan_transactionswith the openingbalance_after. There is no bank/M-Pesa payout call here — disbursement is a bookkeeping state change; the money is handed over out-of-band. - Repayment. Every payroll run, the payroll engine reads active Sacco loans and inserts loan + interest deduction lines (see §5). Payments reduce the outstanding balance; when it hits zero the loan is
completed(archived). - Non-performing & recovery. A scheduled command flags loans that have missed enough payments; an officer then initiates recovery, which pulls from the borrower's shares, then guarantors' shares, then sets up ongoing payroll obligations.
3.2 Recovery detail¶
flowchart LR
S[Outstanding balance] --> B[Step 1: Borrower shares<br/>lock & recover]
B --> R1{Still owed?}
R1 -->|yes| G[Step 2: Guarantor shares<br/>per guaranteed_amount]
G --> R2{Still owed?}
R2 -->|yes| P[Step 3: Payroll recovery<br/>SaccoLoanRecoveryObligation<br/>monthly deduction]
R1 -->|no| C[Recovered]
R2 -->|no| C
P --> C
LoanRecoveryService::calculateRecoveryPreview() computes the waterfall — borrower shares first, then guarantor shares apportioned by each guarantor's guaranteed_amount, then whatever remains is spread across payroll periods as recovery obligations (SaccoLoanRecoveryObligation). Recovery has its own status machine (SaccoLoanRecovery: pending → notified → executing → partial → completed) including a negotiation window (negotiation_deadline, intent_sent_at) before payroll seizure kicks in.
Scheduled jobs (app/Console/Kernel.php):
- sacco:generate-interest-accruals (GenerateSaccoInterestAccruals, Kernel:796) — accrues monthly interest into sacco_loan_interest_accruals.
- sacco:detect-npl (Kernel:827) — flags non-performing loans.
- sacco:negotiation-reminders (Kernel:833) and sacco:check-negotiation-payments (DetectPaymentsDuringNegotiation, Kernel:839) — drive the recovery negotiation window.
The share side is simpler: SaccoShareTransaction rows record every movement of a member's shares, typed deduction (from payroll), adjustment (manual), loan_recovery (shares seized to repay a loan), or bank_deposit (a direct top-up captured via sharesDeposit()). A member's current share balance is therefore the running sum of these rows (contributions and adjustments up, recoveries down). The Sacco Shares screen (hr.sacco.shares) lists members with their payroll_no and share totals, offers a per-member history (hr.sacco.share-history/{userId}), a direct-deposit action (hr.sacco.shares.deposit, a PUT that writes a bank_deposit row), an export (exportShares → SaccoSharesExport), and a bulk-import template so opening balances can be seeded.
3.3 Share contribution flow¶
flowchart LR
E[employees.sacco_deduction<br/>default 500/month] --> P[Payroll run:<br/>DED-SACCO-SHARES line]
P --> ST[SaccoShareTransaction<br/>type=deduction]
B[Officer: direct deposit] --> BD[SaccoShareTransaction<br/>type=bank_deposit]
R[Loan recovery] --> LR[SaccoShareTransaction<br/>type=loan_recovery -ve]
ST --> BAL[Member share balance]
BD --> BAL
LR --> BAL
BAL --> EL[Feeds shares_based<br/>loan eligibility]
The share balance is not just savings — it is collateral: shares_based products cap the loan at shares × eligibility_multiplier, and on default the borrower's (and guarantors') shares are the first thing seized. This closes the loop between the savings and credit halves of the cooperative.
4. Tables touched & key data¶
Core tables (all created 2025–2026):
| Table | Role |
|---|---|
sacco_loan_products |
Loan product config: interest type/rate, eligibility model, guarantor rules, approval type, amount/period limits, gl_account_id, supplier_id |
sacco_loan_eligibilities |
Per-employee, per-product eligible amount (for supplier_determined products); unique on (employee_id, product_id) |
sacco_loans |
The loan record: amounts, interest, monthly deduction, status, the six approval columns, disbursement/completion timestamps, recovery + merge fields, loan_product_id |
sacco_loan_transactions |
Ledger of a loan: disbursement, repayments, with principal_amount / interest_amount / balance_after / accrual_payments (JSON) |
sacco_loan_interest_accruals |
Monthly interest accrual snapshots per loan |
sacco_share_transactions |
Member share movements (deduction / adjustment / loan_recovery / bank_deposit) |
sacco_loan_guarantors + sacco_loan_guarantor_recoveries |
Guarantors and what was recovered from each |
sacco_loan_recoveries / _recovery_obligations / _recovery_payments / _recovery_notifications |
The recovery sub-system |
sacco_loan_topups / _topup_guarantors |
Top-up records |
sacco_loan_drafts / _draft_guarantors / _topup_drafts / _topup_draft_guarantors |
Maker-stage drafts before a real loan/top-up exists |
sacco_settings |
Key/value config (NPL thresholds etc.) |
Two columns bolt Sacco onto the payroll schema:
- employees.sacco_deduction (migration 2025_08_12_113058) — the member's fixed monthly share contribution, float(8,2) defaulting to 500.
- payroll_month_detail_deductions.sacco_loan_id (migration 2025_12_20_173748) — links a payroll deduction line back to the specific loan it repays (ON DELETE SET NULL).
Verbatim quirks worth flagging:
- Interest type enum grew in place.
sacco_loan_products.interest_typewas created asENUM('reducing_balance','compound_fixed')(mig2025_12_19_215647) and laterALTERed to add'simple_fixed'(mig2025_12_23_090337).SaccoLoanServicebranches on all three. bank_depositwas dropped then restored. Thesacco_share_transactions.transaction_typeenum was redefined to('deduction','adjustment','loan_recovery')in Nov-2025, which silently dropped thebank_depositvalue thatsharesDeposit()inserts; migration2026_08_13_100000restores it. The migration comment says so explicitly.- Disbursement opening balance depends on interest model. In
disburseLoan(), forsimple_fixedthe openingbalance_afteristotal_payment(principal + all flat interest); for other types it is justamount(principal). This is a real behavioural fork. - Default share deduction is a hardcoded 500 at the schema level, per employee.
- Cleanup/recalculation utilities exist (
CleanupController::saccoLoansUnrealizedRecalculate*,saccoLoansTopUpOverride*, andclean_sacco_and_loan_datamigration), implying data-drift has needed manual correction.
5. Interactions with other modules¶
5.1 Payroll deduction seam (→ Ch24)¶
This is the central integration. Sacco does not run its own collection; it piggybacks on the payroll deduction engine using three well-known deduction codes:
DED-SACCO-SHARES— the monthly share contribution (amount =employees.sacco_deduction).DED-SACCO-LOAN— loan principal repayment.DED-SACCO-LOAN-INTEREST— loan interest repayment.
In PayrollCalculationService (app/Services/PayrollCalculationService.php:248-305):
1. If the DED-SACCO-SHARES deduction exists and the employee has a sacco_deduction, a shares line is reserved.
2. For each active SaccoLoan, SaccoLoanService::calculatePayrollDeduction($loan, $payrollPeriod) splits the month's payment into principal_amount and interest_amount, and two deduction lines are created — each carrying sacco_loan_id so the repayment ties back to the exact loan.
3. Recovery obligations (SaccoLoanRecoveryObligation, status active) are also folded into the payroll calculation (PayrollCalculationService:398+), so a defaulted loan keeps clawing back via payroll.
So the answer to "how does Sacco recover via payroll": share contributions and loan repayments (principal + interest split) are injected as standard payroll deduction lines keyed by sacco_loan_id, and NPL recovery obligations are injected the same way.
5.2 GL posting seam (→ Book 4)¶
Sacco itself writes no GL rows. There is no wa_gl_trans insert anywhere in HrSaccoController; the controller only reads WaChartsOfAccount when configuring a product's gl_account_id. Instead, the payroll posting services turn the Sacco deduction lines into ledger entries:
PostPayrollGLService::handleSaccoShares()/handleSaccoLoans()(app/Services/PostPayrollGLService.php:266-272, 516+, 605+) build the Sacco legs, grouping loan deductions by loan product so each product credits its owngl_account_id.PayrollMonthGlPostingServicedocuments (:21-37) that it "reproduces every leg that the live posting writes towa_gl_trans", includingbuildSaccoSharesLegs()(:243) andbuildSaccoLoanLegs()(:263), and reads existingwa_gl_transcontext at:476.- Sacco deductions are also emitted as statutory payments to the relevant supplier —
createSaccoSharesStatutoryPayment()/createSaccoLoansStatutoryPayment()(PostPayrollGLService:1217-1290), which route the collected amounts to the product'ssupplier.
So: Sacco posts to the shared wa_gl_trans general ledger (Book 4), but only indirectly — via the payroll GL posting services when a payroll month is posted. It keeps its own operational sub-ledger (sacco_loan_transactions, sacco_share_transactions) for loan/share balances, not a separate double-entry ledger.
5.3 Other seams¶
- Employee master (Ch23): members are
Employeerecords; loans/shares key onemployee_id; branch approval usesemployees.branch_id. - Suppliers / Chart of Accounts (Book 4): each loan product optionally belongs to a
WaSupplierand aWaChartsOfAccount.
6. Alternatives & variants¶
Loan products (sacco_loan_products) are the main configuration surface. A product sets:
- Interest model —
reducing_balance(annuity/amortising, the default),compound_fixed, orsimple_fixed(flat:Interest = Principal × Rate, spread evenly across months, all repayment counted as principal). Rate is annual (interest_rate, default 12%). All three are implemented inSaccoLoanService(calculateReducingBalanceTerms,calculateCompoundFixedTerms,calculateSimpleFixedTermsand matching schedule generators). - Eligibility model —
shares_based(borrow up toshares × eligibility_multiplier, default 2.5×),salary_based(salary × multiplier), orsupplier_determined(limit imported per employee intosacco_loan_eligibilities, e.g. supplier-financed products). - Guarantor rules —
requires_guarantorsandguarantor_type=shares_based(guarantors pledge their shares) orconsent_only. - Approval model —
single(one approver) ormulti_level(branch → HQ → management). - Limits —
min_amount/max_amount,min_period_months/max_period_months(default 1–60). - is_active toggle (
toggleProductActive).
Top-up path. A member with a running loan can borrow more without closing it. It mirrors the main flow with its own draft controller (HrSaccoTopUpDraftController), its own approval queues (topup-approvals.datatable.*), its own gate evaluator (SaccoLoanTopUpGateEvaluator), and tables sacco_loan_topups / sacco_loan_topup_guarantors. The parent loan tracks total_top_up_amount, top_up_count, last_top_up_at. There is also a CleanupController top-up override page (hr.sacco.topup-override).
Loan merge. SaccoLoanMergeService lets multiple loans be merged into one (merged_into_loan_id, merged_at, merged_by; status merged), with a preview step (mergeLoanPreview).
NPL settings are tenant-tunable via sacco_settings (SaccoSetting::getNplConfig() → missed_months_threshold, grace_period_days), editable through hr.sacco.update-npl-settings.
Bulk import paths exist for members, loans, top-ups, guarantors, and eligibilities (bulkImport* methods + importEligibilities), each with a downloadable template.
7. Open questions¶
Code-proven¶
- Sacco recovers share contributions and loan repayments through the payroll deduction engine using codes
DED-SACCO-SHARES,DED-SACCO-LOAN,DED-SACCO-LOAN-INTEREST, tagged withsacco_loan_id(PayrollCalculationService:248-305). (Proven.) - Sacco has no direct
wa_gl_transwrite; GL legs are produced by the payroll GL services (PostPayrollGLService,PayrollMonthGlPostingService), which explicitly targetwa_gl_trans. (Proven.) - Disbursement (
disburseLoan) is a status change + ledger row only — no bank/M-Pesa payout call. (Proven — nothing in the method calls a payment gateway.) - Three interest models and three eligibility models exist, per the product enums and
SaccoLoanService. (Proven.)
Inference (needs confirmation)¶
- How disbursed cash physically reaches the member (cash, EFT batch, added to a pay run, or manual) is not visible in the disburse path; likely handled off-system or via a batch outside this module. (Inference.)
- Whether the
sacco-loans___management_approvepermission ↔hq_gm_approved_*columns mapping is intentional naming drift or a latent bug is not verifiable statically — the labels ("management") and columns ("hq_gm") diverge. (Inference.) - Whether legacy
wa_sacco/wa_payroll_saccotables are still read anywhere (I did not find live references in the Sacco controller/services). (Inference — assumed dead.) - Exact GL account posting direction (which side each Sacco leg debits/credits) was not traced line-by-line;
PayrollMonthGlPostingService:33comments "CR Sacco Shares / Sacco Loans (per product)" suggesting credit legs, but the full journal was not fully expanded here. (Inference.) - The precise trigger that flips a fully-repaid loan to
status='completed'(payroll post vs. a repayment handler) was not pinned to a single line. (Inference.)
8. Source references¶
Routes:
- routes/modules/hr.php:553-726 — the whole admin/hr/sacco route group (dashboard, shares, loans, drafts, top-ups, approvals, NPL, recovery, products, eligibilities, dashboard API).
- routes/modules/hr.php:634-651 — approval + top-up-approval queues per level.
- routes/modules/hr.php:548-570 — NPL & recovery routes.
Controllers:
- app/Http/Controllers/Admin/HrSaccoController.php — index, shares, sharesDeposit, loansList, showLoan, disburseLoan, approveLoan, rejectLoan, nonPerformingLoans, initiateRecovery, executeRecovery, product & eligibility CRUD (~6,290 lines).
- app/Http/Controllers/Admin/HrSaccoLoanDraftController.php — draft capture/evaluate/submit.
- app/Http/Controllers/Admin/HrSaccoTopUpDraftController.php — top-up drafts.
- app/Http/Controllers/CleanupController.php — top-up override & unrealized-interest recalculation utilities.
Services:
- app/Services/SaccoLoanService.php — interest math + calculatePayrollDeduction().
- app/Services/LoanRecoveryService.php — recovery waterfall (calculateRecoveryPreview).
- app/Services/NplDetectionService.php — NPL detection (SaccoSetting::getNplConfig).
- app/Services/PayrollCalculationService.php:248-305, 398+ — Sacco deduction injection into payroll.
- app/Services/PostPayrollGLService.php:266-272, 516+, 605+, 1217-1290 — Sacco GL legs + statutory payments.
- app/Services/PayrollMonthGlPostingService.php:21-37, 243-296, 471-476 — wa_gl_trans leg reproduction for Sacco.
- app/Services/SaccoLoanGateEvaluator.php, SaccoLoanTopUpGateEvaluator.php, SaccoLoanMergeService.php, SaccoLoanNotificationService.php.
Models (app/Models/): SaccoLoan.php (statuses, isFullyApproved(), isSingleApproval()), SaccoLoanProduct.php, SaccoShareTransaction.php, SaccoLoanTransaction.php, SaccoLoanRecovery.php, SaccoLoanRecoveryObligation.php, SaccoSetting.php, plus guarantor/topup/draft/eligibility models.
Migrations (database/migrations/): 2025_08_11_085128_create_sacco_loans_table.php, 2025_08_12_113058_add_sacco_deduction_to_employee_table.php, 2025_12_20_173748_add_sacco_loan_id_to_payroll_month_detail_deductions.php, 2025_12_19_215647_create_sacco_loan_products_table.php, 2025_12_23_090337_add_simple_fixed_to_sacco_loan_products_interest_type.php, 2025_12_19_215754_create_sacco_loan_eligibilities_table.php, 2025_11_05_105445_create_sacco_loan_recoveries_table.php, 2026_02_24_181815_create_sacco_loan_recovery_obligations_table.php, 2025_11_05_111613_... & 2026_08_13_100000_add_bank_deposit_type_to_sacco_share_transactions.php, 2026_08_14_090000_create_sacco_loan_drafts_table.php.
Nav / permissions: resources/views/admin/includes/sidebar_includes/hr.blade.php.
Scheduled jobs: app/Console/Kernel.php:796, 827, 833, 839; commands app/Console/Commands/GenerateSaccoInterestAccruals.php, DetectPaymentsDuringNegotiation.php.