Dentolize · HR Module Walkthrough
On this pageBusiness viewTechnical view

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

computeLineNetaccounting/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

startPayrollRundraftPayrollRun (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 in salaryConfigMutations.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): validates 0 < 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: EmployeeLoan schema.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):

CodeAccountRoleType
1260Employee Advances & LoansEMPLOYEE_ADVANCESAsset (current)
2130GOSI PayableGOSI_PAYABLELiability (current)
2620End-of-Service ProvisionEOS_PROVISIONLiability (non-current)
5220GOSI Employer ContributionsGOSI_EXPENSEExpense
5230End-of-Service ExpenseEOS_EXPENSEExpense

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.