Skip to content

Chapter 23 — Employees & Payroll

Book 6 (HR & Payroll). Covers the employee master (drafts, employees, casuals), HR configurations (general/payroll/banking), the monthly permanent-staff payroll run, casuals-pay disbursement, commission & incentives, outsourced payouts, and payroll reports. Statutory deductions (NSSF/HELB/medical/benevolent) and advances/penalties are covered in Ch24; leave/attendance/devices in Ch25; Sacco in Ch26. This chapter references those seams but does not own them.

1. Purpose

Plain-language: this is the heart of Bizwiz HR. It holds the employee master — every permanent staff member and every casual worker — and it runs payroll. Two very different pay engines live here:

  1. Permanent-staff monthly payroll. For salaried employees, HR opens a "payroll month", the system calculates each person's gross, statutory deductions (PAYE, NSSF, SHIF, Housing Levy), other deductions (Sacco, advances, HELB, penalties) and net pay, an approver signs it off, payslips go out, and the totals are posted to the general ledger.
  2. Casuals pay. Daily/temporary workers are paid per pay period off an attendance register. Once a supervisor approves the period, each casual is paid straight to their M-Pesa via Safaricom Daraja B2C, and each successful payment books its own bank/expense GL entry.

Alongside these sit commission & incentives (sales/tonnage commission bands, item-rate incentive batches with targets and earnings) and outsourced payouts (paying third-party/outsourced staff off closed incentive batches or commission periods). Payroll reports provide the paymaster/statutory views.


Technical: nav is resources/views/admin/includes/sidebar_includes/hr.blade.php. Routes live in routes/modules/hr.php (web) and routes/api_includes/hr.php (API). The permanent payroll engine is a set of queued jobs (ProcessEmployeePayrollJob, ProcessPostPayrollJob, ProcessPayrollGLPostingJob) orchestrated from PayrollMonthController. Casuals pay is CasualsPayPeriodController + CasualPayDisbursementController + the InitiateCasualDisbursement job. The GL spine is Book 4's wa_gl_trans (model WaGlTran).

2. Users & roles

Plain-language: access is gated by fine-grained permission strings and by a special "HR access" flag that lets branch HR users see casuals/employees without full admin rights. role_id == 1 is the superadmin who bypasses all permission checks.


Technical: permission gate helpers are can('view'|'approve', '<module-string>') and isset($my_permissions['<perm>']) in the sidebar; controllers also use $logged_user_info->hasHrAccess(). Permission strings observed:

Area View permission Notes
Whole module hr-and-payroll___view plus hasHrAccess()
Configurations hr-and-payroll-configurations___view, ...-general___view, ...-payroll___view, ...-banking___view
HR Management hr-and-payroll-hr-management___view, hr-management-employee-drafts___view, hr-management-employees___view, hr-management-casuals___view casuals also open to hasHrAccess()
Payroll hr-and-payroll-payroll___view, payroll-payroll-months___view
Casuals pay casuals-pay-successful-disbursements, casuals-pay-failed-disbursements, casuals-pay-expunged-disbursements checked via can('view', ...)
Casual disburse action petty_cash_casual_disburse TaskCenter task key (CasualsPayPeriodController@casualsPayPeriodDetailsApprove line ~412)
Payouts commission-and-incentives-payouts___view / ...___approve OutsourcedPayoutController::$pmodule

Approvals: permanent payroll uses a multi-step approval wizard (validateApproval, initiateApproval, getApprovalProgress) with an approval_status distinct from status. Payout approval is a state machine on OutsourcedPayment (markAsApproved(auth()->id()), canApprove()), requiring prior validation.

3. Processes

3a. Employee master lifecycle

Employees are created as drafts first (Employee::where('is_draft', true), a global draft scope hides them; HRManagementController@employeeDrafts reads withoutGlobalScope('draft')). A draft is promoted via employeesCreate?id={draftId}. Bulk upload templates exist for create, termination, salary-bank, and dynamic-update (HrBulkUploadController). Casuals have their own draft/duplicate/approval flow (casuals/approval, handleApproval, handleBulkApproval) and a global expunged scope on disbursements.

3b. Permanent monthly payroll run

flowchart TD
    A[HR opens Payroll Month<br/>status = open] --> B[processPayroll:<br/>status = processing<br/>dispatch ProcessEmployeePayrollJob per employee]
    B --> C[PayrollCalculationService.calculateEmployeePayroll<br/>gross, PAYE, NSSF, SHIF, Housing Levy,<br/>deductions, net_pay per PayrollMonthDetail]
    C --> D{failures?}
    D -- yes --> E[PostPayrollProcessingFailure<br/>retry / reset / reprocess]
    D -- no --> F[validateApproval / verification results]
    F --> G[initiateApproval<br/>status/approval_status = approved]
    G --> H[per detail: ProcessPostPayrollJob<br/>PostPayrollService.Process<br/>settle advances, sacco, helb, penalties...]
    G --> I[ProcessPayrollGLPostingJob<br/>PostPayrollGLService.Process<br/>write legs to wa_gl_trans]
    I --> J[create statutory payment records<br/>PAYE / NSSF / SHIF / Housing / Net Pay / Sacco / HELB]
    H --> K[bulk payslips emailed<br/>status = closed]
    I --> K

Key controller anchors in PayrollMonthController: processPayroll sets status = 'processing' and fans out ProcessEmployeePayrollJob (line ~133); initiateApproval/approveEditRequest set status='approved' then dispatch ProcessPostPayrollJob per detail and ProcessPayrollGLPostingJob::dispatch($id) (lines ~2072-2074, ~2136-2139). Closed months are locked (status === 'closed' guards at lines 69, 421). There is a payroll edit-request sub-flow (submit / approve / reject an edit to an individual line after processing) at lines ~1768-1891.

Statutory calculation order (PayrollCalculationService@calculateEmployeePayroll, lines 48-115): builds separate gross buckets per statutory (each earning declares affectsStatutory('nssf'|'shif'|'housing_levy'|'paye')), pro-rates by a ratio (partial-month), computes NSSF (tiered), SHIF (max(gross×rate/100, 300)), Housing Levy (gross×rate/100), then taxablePay = payeGross − nssf − housingLevy − shif − nssfVoluntary, then tiered PAYE with tax relief. Net pay uses CASH gross only (excludes non-cash benefits) — line 111.

3c. Casuals pay disbursement (M-Pesa B2C)

flowchart TD
    A[Open pay period<br/>casualsPayPeriodsOpen] --> B[Attendance register / upload<br/>casualsPayPeriodDetails]
    B --> C[Supervisor approves<br/>casualsPayPeriodDetailsApprove<br/>status = closed, final_approval]
    C --> D[per detail: create CasualsPayDisbursement<br/>disbursed = true]
    D --> E[InitiateCasualDisbursement job afterCommit<br/>DisbursementService.disburse]
    E --> F[Daraja B2C to casual phone_no]
    F -->|result callback| G{call_back_status}
    G -- complete --> H[Successful disbursements page<br/>+ write GL: DR expense 56002-033,<br/>CR bank 988329, WaBanktran]
    G -- not complete / null --> I[Failed disbursements page<br/>retry / resend if not locked]
    G -- removed --> J[Expunged disbursements<br/>global 'expunged' scope]

The disburse fan-out is CasualsPayPeriodController@casualsPayPeriodDetailsApprove (lines ~420-449): guards against re-disbursing a closed/final_approval period, creates one CasualsPayDisbursement per detail with a narrative "Casual pay disbursement for {full_name} ({phone_no}). Period: {start}-{end}", then InitiateCasualDisbursement::dispatch($disbursement, $this->disbursementService)->afterCommit(). The job (app/Jobs/InitiateCasualDisbursement.php) calls $disbursementService->disburse(...) and stores the originator_conversation_id; locked_for_resend guards double-sends. DisbursementService is bound to MpesaDisbursementService (via App\Interfaces\DisbursementService) in AppServiceProvider (lines 244-245), which drives Daraja B2C. The result callback lands in CasualPayDisbursementController (~line 260): on call_back_status = 'complete' it books the bank + GL legs (see §5).

3d. Commission, incentives & payouts

  • Commission (OutsourcedCommissionController): sales-commission and tonnage-commission configs (bands per job title), commission pay periods with results (STATUS_CLOSED).
  • Incentives (IncentiveController): incentive batches with a rich lifecycle — draft → submit → activate → close/reopen, item rates & targets per employee, then earnings calculated per batch/employee. Legacy per-item global rates plus batch-specific rate/target uploads.
  • Payouts (OutsourcedPayoutController): create a payout tied to a closed incentive batch or closed commission period (store, lines ~94-247; guards IncentiveBatch::STATUS_CLOSED / OutsourcedPayPeriod::STATUS_CLOSED). Lifecycle OutsourcedPayment: DRAFT → validated → approved (markAsApproved) → paid (markAsPaid) → "Send to Accounts" creates a statutory payment against a chosen GL account (createStatutoryPayment). This is the bridge to Accounts Payable rather than the payroll GL journal.

4. Tables touched & key data

Plain-language: the permanent payroll data is a header (payroll_months) with one detail row per employee, plus pivot tables for that employee's earnings and deductions. Casuals have their own period → detail → disbursement chain.


Technical (migrations under database/migrations):

Table Key columns / notes
employees is_draft, payment_mode_id, terminated_date, active, NSSF opt-out fields (nssf_tier2_opted_out, nssf_pension_provider_id). Global draft scope.
casuals casual_type, approval columns, phone_no (fixed in 2024_11_14 migration).
payroll_months start_date, end_date, status default 'open' (open/closed) — migration comment only lists two states; code also uses processing, validated, approving, approved, approval_failed, closed, plus a separate approval_status. document_no like PM-#####.
payroll_month_details one row per employee: basic_pay, gross_pay, paye, nssf, shif, net_pay etc. Extended by 2024_10_30 and 2025_08_29 migrations.
payroll_month_detail_earnings pivot of earnings per detail.
payroll_month_detail_deductions pivot of deductions per detail (PayrollMonthDetailDeduction); all deductions (system-reserved + user-defined) land here and net_pay sums from it.
casuals_pay_periods status (open/closed), final_approval, archived, casual_type.
casuals_pay_period_details per-casual register line; disbursement_details, disbursement_lock.
casuals_pay_disbursements call_back_status (complete = success), originator_conversation_id, document_no, reference, amount, narrative, disbursed, expunged (global scope), locked_for_resend, multiple.
casuals_branch_pays, casual_job_level_branch_pays per-branch/job-level casual rates.
wa_commission, wa_payroll_commission, wa_sales_commission_bands commission config/bands (2023 migrations).
incentive_settings (+ group) incentive setup; incentive batches/items/targets/earnings tables.
salesman_supplier_incentive_earnings supplier/salesman incentive earnings.
wa_gl_trans GL journal (Book 4 spine) — see §5.
wa_banktran bank-side leg for casual disbursements.

Verbatim quirks / misspellings: - Controller HrcausalsBranchPay.php and route method causalAproval / duplicatecasuals — "causal"/"Aproval" misspellings. - wa_curreny_id (misspelled "currency") set on the casual WaBanktran leg (CasualPayDisbursementController ~line 280). - WaNumerSeriesCode model — "Numer" (number) misspelled throughout. - Migration 2025_02_13_104001_modify_sm_casuals_table.php — sm_ prefix on casuals.

5. Interactions with other modules

5a. Payroll → GL (priority seam) — CONFIRMED

Permanent payroll posts to wa_gl_trans via PostPayrollGLService::Process() (dispatched by ProcessPayrollGLPostingJob). PayrollMonthGlPostingService is its read-only rebuild twin (used by the GL-cleanup "Wrong Postings" utility) and documents every leg (file header, lines 26-37; build logic lines 90-163). Transaction type = the PAYROLL number-series description (WaNumerSeriesCode module PAYROLL, type_number default 73); document PM-#####; posted per branch (amountsByBranchFromColumn). Legs per payroll month:

Leg Dr/Cr Amount Component
Gross Pay DR +SUM(gross_pay) salary expense
Net Pay CR −SUM(net_pay) net-pay liability / bank
PAYE CR −SUM(paye) statutory liability
SHIF CR −SUM(shif) statutory liability
NSSF CR employee (statutory+voluntary), DR employer (statutory), CR employee (statutory) 3 legs employee + employer accounts
Housing Levy CR employee ×2, DR employer 3 legs employee + employer accounts
Sacco Shares / Sacco Loans (per product) / Loan Recoveries / Salary Advance / dynamic deductions / HELB / Penalties CR −SUM(amount) each mapped deduction accounts; skipped silently when unmapped or zero

GL account resolution is via PayrollGlMapping → WaChartsOfAccount (requireAccountCode, deductionAccountByCode). After GL posting, PostPayrollGLService::Process (lines 307-359) creates statutory payment records for PAYE/NSSF/SHIF/Housing Levy/Net Pay/Sacco/HELB/Benevolent (handoff to the payments/AP module). Statutory payments and cash payments deliberately do not hit wa_gl_trans (header note lines 36-37).

5b. Casuals pay → GL & M-Pesa — CONFIRMED

On a complete B2C callback (CasualPayDisbursementController ~lines 262-309): number series CASUAL_PAY_DISBURSEMENT, then: - CR bank — WaBanktran against bank account 988329, amount × -1, wa_payment_method_id = 11 (labelled "PETTY CASH"). - DR expense — WaGlTran to account 56002-033, amount, and a matching CR bank GL leg. - Quirk: restaurant_id/tb_reporting_branch are hardcoded to 10 (MAKONGENI) and cashier_id = 1. So all casual-pay GL lands on one branch regardless of the casual's actual branch — worth flagging. This mirrors the Book 4 petty-cash disbursement pattern (same M-Pesa B2C rail, same "petty cash" payment method), and casual views live under petty_cash.normal_casual_disbursements.*.

5c. Driver comp open loop (Book 5 carry-forward) — NOT consumed here (negative finding)

Grep for driver_tyre_incentive, driver_tyre_mismatch, DriverTyreIncentive, tyre_incentive, and driver.*incentive across PayrollCalculationService, PostPayrollService, PostPayrollGLService, PayrollMonthGlPostingService, and the commission/incentive/payout controllers (OutsourcedCommissionController, IncentiveController, OutsourcedPayoutController, CommissionController) returns no matches. The driver-tyre / fuel-service driver incentives & mismatch charges flip a status in Book 5 but are not read by this chapter's payroll, commission, incentive, or payout code. The open loop from Book 5 remains open. (DriverTyreIncentiveService exists in app/Services but is a Book-5 fleet concern, not wired into payroll here.)

5d. Other seams

  • Advances / penalties / Sacco / HELB / benevolent / medical — PostPayrollService settles these against the employee's balances during post-payroll (methods processSalaryAdvance, processPenalties, processSaccoLoans, processHelb, processBenevolent, processMedicalCover, processLoanRecoveries). Owned by Ch24/Ch26; payroll is the consumer.
  • Attendance (Ch25) feeds casual pay-period registers and permanent partial-month ratio.
  • TaskCenter — casual disburse completes task petty_cash_casual_disburse (TaskCenterService::complete).
  • Payouts → Accounts — createStatutoryPayment bridges outsourced payouts into the statutory-payment/AP path rather than the payroll journal.

6. Alternatives & variants

  • Permanent vs casuals: entirely separate engines. Permanent = monthly payroll_months with statutory calc + GL journal + statutory-payment handoff; casuals = per-period register + immediate M-Pesa B2C + self-contained bank/expense GL.
  • Casual type / branch pay: casual_type on periods and casuals_branch_pays / casual_job_level_branch_pays allow per-branch, per-job-level casual rates; project casuals (project_casuals) are a further variant.
  • NSSF opt-out: nssf_tier2_opted_out + nssf_pension_provider_id branch the NSSF provider (PayrollCalculationService ~line 115).
  • Statutory floors/rates are config-driven: SHIF has a max(..., 300) floor; NSSF is tiered; Housing Levy and SHIF rates come from config models loaded in the calc service constructor.
  • Deduction mapping optional: optional deduction GL legs (Sacco/HELB/advance/penalty/dynamic) are silently skipped when unmapped or zero, so a payroll month can post a valid (still-balanced on core legs) journal even with incomplete mapping.
  • Payout source: a payout can be built off a closed incentive batch or a closed commission period — two branches in store.
  • Superadmin bypass: role_id == 1 skips every permission check.

7. Open questions to confirm

Code-proven facts: - Permanent payroll posts to wa_gl_trans via PostPayrollGLService; legs and per-branch split confirmed (§5a). - Casual pay disburses over Daraja B2C (MpesaDisbursementService) and books DR 56002-033 / CR bank 988329 on complete callback (§5b). - Driver-tyre/driver incentives & mismatch charges are not referenced by payroll/commission/incentive/payout code (§5c). - Casual GL legs hardcode branch 10 (MAKONGENI) and cashier_id = 1. - Payouts feed a statutory payment ("Send to Accounts"), not the payroll journal.

Inferences / unknowns: - Exact PayrollGlMapping account codes (e.g. which COA is gross_pay expense vs net_pay liability) are DB-config, not in code — not verifiable by static analysis. - Whether the hardcoded casual bank 988329 / branch 10 is intentional single-till design or a latent multi-branch bug — needs product confirmation. - Whether MpesaDisbursementService vs PesaFlowDisbursementService / DarajaDisbursementService is the active binding in every tenant — AppServiceProvider binds MpesaDisbursementService, but a conditional binding at line ~182 references DarajaDisbursementService; the tenant-selection logic was not fully traced. - Full incentive earnings → payslip earning path: whether incentive/commission earnings flow into payroll_month_detail_earnings for permanent staff, or only via outsourced payouts, was not conclusively traced (incentive earnings appear consumed by payouts, not the monthly run). - Benevolent-fund statutory payment is created in PostPayrollGLService (line ~359) even though benevolent is a Ch24 concern — confirm ownership boundary.

8. Source references

  • Nav: resources/views/admin/includes/sidebar_includes/hr.blade.php
  • Routes: routes/modules/hr.php:27-40 (configs), :40-73 (management/casuals), :142-224 (payroll months lifecycle), :276-285 (casuals-pay), :289-309 (commission), :335-417 (incentives), :419-427 (payouts), :502 (reports)
  • Controllers: app/Http/Controllers/Admin/HRConfigurationsController.php, HRManagementController.php:35-46,254-268, PayrollMonthController.php:69,133,411-421,644-673,2040-2074,2097-2139,2176-2250, CasualsPayPeriodController.php:41,386-455, CasualPayDisbursementController.php:47-171,260-309, OutsourcedCommissionController.php, IncentiveController.php, OutsourcedPayoutController.php:94-247,336-420, HrPayrollReportController.php, HrBulkUploadController.php
  • Services: app/Services/PostPayrollGLService.php:214-359,365-739, PayrollMonthGlPostingService.php:16-163,182-208,218 (leg spec), PayrollCalculationService.php:48-115, PostPayrollService.php:51-622, PayrollJournalService.php, MpesaDisbursementService.php, EmployeePayrollSetupService.php
  • Jobs: app/Jobs/InitiateCasualDisbursement.php:25-49, ProcessPayrollGLPostingJob.php, ProcessPostPayrollJob, ProcessEmployeePayrollJob
  • Binding: app/Providers/AppServiceProvider.php:182,244-245
  • Migrations: database/migrations/2024_10_11_074416_create_payroll_months_table.php, ..._create_payroll_month_details_table.php, ..._create_payroll_month_detail_earnings_table.php, ..._create_payroll_month_detail_deductions_table.php, 2024_10_20_..._create_casuals_pay_periods_table.php, 2024_11_04_..._create_casuals_pay_disbursements_table.php, 2024_07_17_..._create_employees_table.php, 2024_10_18_..._create_casuals_table.php