Payroll Money Paths
The heart of the module. Every number here becomes a balanced journal entry in the same ledger the clinic invoices from.
Business view
Running payroll is a three-step cycle: Draft → Confirm → Process.
- Draft builds a line for every employee: base salary (prorated if they joined
or left mid-month), commission, overtime, allowances, minus late/absence/leave deductions, minus loan installments, minus the employee's GOSI share.
- Confirm locks the draft for payment (or it's auto-confirmed, depending on
mode).
- Process actually pays it — posting salaries, settling loans, withholding
GOSI, and accruing end-of-service to the general ledger.
Re-starting a month gives you back the existing draft untouched, so any manual line edits survive. Only Process moves money.
What's computed along the way:
- Proration — a mid-month joiner or leaver is paid by calendar days
employed.
- Allowances & deductions — structured salary components (housing,
transport, a fixed deduction), each optionally counted in the GOSI base.
- Loans & advances — disbursed from a treasury (Dr Employee Loans / Cr
Cash), then withheld in future runs, re-checked against the live balance so a manual repayment in between never causes double-withholding.
- GOSI — the employee's share is withheld from net; the employer's share is
accrued as a company cost. Rates are your settings.
- End-of-service — a monthly provision builds up automatically (half-month per
year under 5 years, full month after) so the liability is always on the books.
- Doctors are different: they're paid base + commission via the doctor
payout path and skip GOSI, loans, allowances, and attendance.
Technical view
Money code lives in packages/server/src/accounting/payroll/; the GraphQL mutations (resolvers/mutations/actions/payroll/payrollRunMutations.js) are a thin layer over it. All display/line amounts round with round2 = Math.round((n + EPSILON) * 100) / 100.
The one authoritative net formula
computeLineNet — accounting/payroll/payrollMath.js:15:
net = round2(
base + commission + overtime + allowances + adjustments
− late − absence − leave − otherDeductions − loanDeduction − gosiEmployee )
It's called by the draft (payrollDraft.js:257), the line edit (payrollRunMutations.js:68), and the process-time loan re-check (payrollRun.js:124) — deliberately the single place net is computed, to kill the "a component silently dropped from one path" bug class. Note it excludes gosiEmployerAmount (an employer cost, never withheld).
Lifecycle
startPayrollRun → draftPayrollRun (payrollDraft.js:28); mode from PayrollSettings. Draft status is CONFIRMED if mode is AUTO else PENDING_REVIEW (:283). Idempotent on (companyId, year, month) — a re-draft returns the existing non-processed run so edits survive (:29). confirmPayrollRun (payrollRun.js:22), processPayrollRun (:56); a PROCESSED run cannot be cancelled (:253).
Proration (calendar-day) — payrollMath.js:35
employmentFactor: employedDays = round((min(periodEnd,term) − max(periodStart,hire))/dayMs) + 1 (inclusive); factor = min(1, round(employedDays/daysInMonth, 4dp)), 0 if fully out of range. Applied to the fixed monthly base only (payrollDraft.js:165) and to EARNING allowances (:204); hourly base and fixed DEDUCTION components are not prorated (:205). A zero-factor employee is skipped — except a terminated doctor still owed commission, who gets a commission-only line (:148).
GOSI — payrollDraft.js:210
Only for non-doctors with gosiApplicable. gosiBase = gosiBaseOverride ?? round2(base + prorated GOSI-flagged EARNING components); gosiEmployee = round2(base × gosiEmployeeRate/100), gosiEmployer = round2(base × gosiEmployerRate/100).
Honest divergence: the rates are fully company-configurable (PayrollSettings.gosiEmployeeRate/gosiEmployerRate, validated only to 0–50 insalaryConfigMutations.js:125). There are no hard-coded Saudi statutory percentages anywhere. The employee share is withheld in net; the employer share is accrued, not withheld.
Loans — hr/employeeLoanMutations.js + payrollRun.js:107
- Disburse (
createEmployeeLoan:54): validates0 < installment ≤ principal;
in one transaction creates the loan (balance = principal), moves cash from a treasury, and pre-parks the posting intent → Dr EMPLOYEE_ADVANCES (1260) / Cr Cash.
- Payroll withholding: draft caps the installment so net can't go negative
(payrollDraft.js:222); at process time it's re-checked against live balances (payrollRun.js:113) — applied = min(drafted, liveDue) — and the line's net is recomputed via computeLineNet if it changed. Installments apply oldest-first; a fully-repaid loan flips PAID; each creates an EmployeeLoanPayment with the payrollRunLineId.
- Manual repayment (
recordEmployeeLoanPayment:145): a treasury is
mandatory (else the cash leg drifts); posts Dr Cash / Cr 1260.
- Models:
EmployeeLoanschema.prisma:3908,EmployeeLoanPayment:3930.
The withholding GL top-up — payrollWithholdings.js:25
Because the salary Expense books only net cash, withheld amounts are topped up so salary expense reflects pay gross-of-withholdings:
Dr SALARY_EXPENSE (loanDeduction + gosiEmployee)
Cr EMPLOYEE_ADVANCES (loanDeduction) — settles the 1260 loan asset
Cr GOSI_PAYABLE (gosiEmployee) — employee share withheld
Dr GOSI_EXPENSE (gosiEmployer) / Cr GOSI_PAYABLE (gosiEmployer) — employer accrual
Honest divergence: late/absence/leave/otherDeductions are not topped up — they simply reduce salary expense (and net). So "salary expense = gross" holds only for loan + GOSI withholdings; attendance and structured-deduction reductions permanently lower the expense rather than crediting a liability.
End-of-service accrual — payrollRun.js:212
Runs only if eosEnabled. For each PAID line with base > 0 and a hire date: serviceYears = (periodEnd − hireDate) / (365.25 × dayMs); monthsFactor = serviceYears < 5 ? 0.5 : 1; amount = round2((base × monthsFactor) / 12). One EosAccrual row per employee (idempotent), then one aggregate JE Dr EOS_EXPENSE / Cr EOS_PROVISION.
Honest divergences from KSA mukafa'a: this is a monthly straight-line provision (base/24 under 5y, base/12 after), using the current-period base each month and base only (ignores allowances). The 5-year cliff flips the entire monthly accrual at once — no within-year 1/3-vs-2/3 blending. It's a sound provisioning approximation, not a certified settlement engine.
End-of-service payout (hr/eosMutations.js:40): provisionReleased = min(remainingProvision, amount); posts Dr EOS_PROVISION (released) + Dr EOS_EXPENSE (excess) / Cr Cash — a payout can exceed the built-up provision, and the excess is expensed immediately. Models: EosAccrual schema.prisma:3945, EosPayment :3962.
Salary cash expense — salaryExpense.js:25
performSalaryExpense creates a paid Expense (reserved EXPENSES_SALARY category) with a treasury payment, posting the payment leg inline (Dr AP / Cr Cash) and the accrual leg resiliently (Dr Salary Expense / Cr AP) → net Dr Salary Expense / Cr Cash. Its onCreated callback runs settleLine and parks the withholding intent atomically.
Exactly-once settlement — payrollRun.js:47
settleLine does updateMany({ where:{ id, status:'PENDING' }, data:{ status:'PAID' }}); 0 rows matched → throws LINE_ALREADY_SETTLED to roll back the just-created payout/expense. This makes each line pay exactly once under retry/concurrency. updatePayrollRunLine also refuses to un-PAID a PAID line (payrollRunMutations.js:58).
Doctors — payrollRun.js:78
At process time the doctor's outstanding payable is re-read and commission capped to it (min(line.commissionAmount, outstanding)) — because the drafted commission was the full outstanding at draft time, and two runs drafted before either processed would otherwise double-pay. Paid via performDoctorPayout.
The five new ledger accounts
Seeded (accounting/coaTemplate.js) and role-mapped (accounting/accountRoles.js:82, accounting/posting/engine.js:67):
| Code | Account | Role | Type |
|---|---|---|---|
| 1260 | Employee Advances & Loans | EMPLOYEE_ADVANCES | Asset (current) |
| 2130 | GOSI Payable | GOSI_PAYABLE | Liability (current) |
| 2620 | End-of-Service Provision | EOS_PROVISION | Liability (non-current) |
| 5220 | GOSI Employer Contributions | GOSI_EXPENSE | Expense |
| 5230 | End-of-Service Expense | EOS_EXPENSE | Expense |
Roles resolve by PostingRule → settings field → conventional code → fallback (AP/AR/EXPENSE), so tenants that haven't seeded the accounts mid-deploy still post somewhere sane instead of failing (engine.js:125).
Config & validation
updateSalaryProfile / updatePayrollSettings (salaryConfigMutations.js:36, :96) — tenure years 1–40, tenure days 0–365, max daily hours 4–24, GOSI rates 0–50, eosEnabled, WPS ids, WhatsApp toggles. Loan input createEmployeeLoanInput (inputRules.js:4075): principal/installment 0.01–10,000,000. Component upsertSalaryComponentInput (:4061): amount 0.01–1,000,000.
See WPS / SIF Export for the bank file, and Automation for the resilient posting/outbox that makes all of this crash-safe.