Dentolize · HR Module Walkthrough
On this pageInvariants that must always holdPayroll math — targeted casesLeave & attendanceSecurity & tenancy (test as an adversary)Input validation (Yup + imperative)Bulk importCron & notificationsReportsRegression seams worth a smoke test each release

For Quality

What to test, and where the edges are. Organized by risk. The highest-value tests are the money invariants — a payroll defect is real money and lost trust.

Invariants that must always hold

These are the "if this ever breaks, it's a Sev-1" checks.

  1. Trial balance stays balanced after every processed run. Journal entries are

posted on functional-currency totals; a processed run must not unbalance the GL.

  1. WPS control total == run Total Net. The export builds the deductions column

as basic + housing + other − net, so basic + housing + other − deductions = net by construction. If the SCR trailer total ≠ the run's net, that's a defect.

  1. Each payroll line pays exactly once. Settlement uses a guarded

updateMany(status: PENDING → PAID); re-processing or concurrent processing must not double-pay. Test: process, retry/replay, assert one payout.

  1. Subledgers reconcile to the GL (Δ0). Loans (1260), GOSI payable (2130),

end-of-service provision (2620) should match their control accounts.

  1. computeLineNet is the only net formula. Draft, line-edit, and

process-time loan re-check all call it (payrollMath.js:15). A regression test should assert that a component added to one path shows up in all three.

Payroll math — targeted cases

Proration (payrollMath.js:35, employmentFactor):

  • Joiner mid-month, leaver mid-month, both — inclusive of both endpoints,

factor = employedDays / daysInMonth capped at 1.

  • Fully-outside-period employee → skipped; **except a terminated doctor with

outstanding commission** → commission-only line (base 0).

  • **Fixed base and EARNING allowances are prorated; hourly base and fixed

DEDUCTION components are NOT.** Test all four combinations.

Overtime (attendanceSummary.js):

  • With approval required on, unapproved overtime is dropped and capped at

scheduled minutes (can't leak into 1× base). Approve → it appears.

  • Regular-day OT at overtimeMultiplier (default 1.5) vs rest-day/holiday work at

restDayOvertimeMultiplier (default 2) — separate buckets, no double count.

Missing-checkout guard (userMutations.js:816):

  • A session longer than maxDailyWorkedHours (default 16h) is capped and

autoClosed set. Verify the cap value is company-configurable (4–24).

GOSI (payrollDraft.js:210):

  • Only non-doctors with gosiApplicable. `gosiBase = override ?? base +

GOSI-flagged EARNING components`. Employee share withheld in net, employer share accrued (not withheld).

  • Rates are settings (0–50), not hard-coded. Test that changing the company

rate changes the amounts; there are no statutory constants to assert against.

End-of-service (payrollRun.js:212):

  • Accrues only when eosEnabled, only for lines with base > 0 and a hire date.
  • monthsFactor = serviceYears < 5 ? 0.5 : 1; amount = base × factor / 12.
  • Edge to verify honestly: the 5-year cliff flips the entire monthly accrual

at once (no within-year blend); it uses current base and base only (ignores allowances). Confirm this is the intended approximation.

  • Re-processing that adds accrual rows triggers a reverse-and-repost of the

aggregate JE — test idempotency and the growth case.

Loans (employeeLoanMutations.js, payrollRun.js:107):

  • Disbursement posts Dr 1260 / Cr Cash; requires a treasury.
  • Payroll withholding is re-checked against the live balance at process time

test: manual repayment between draft and process → the payroll deduction shrinks to match (no over-withholding).

  • Manual repayment requires a treasury (else GL cash leg drifts); oldest loan

settles first; fully repaid → Paid.

  • Loan deduction is capped so net never goes negative.

Leave & attendance

  • Self-approval blocked (hrMutations.js:320). Also self-review and

self-warn.

  • Paid-leave cap, including the hardening that a paid type with **no balance

entry counts as 0 remaining** (hrMutations.js:247). Unpaid uncapped.

  • Tenure leave day-accurate blend across the anniversary year

(hrQueries.js:108) — test just-before / on / just-after the anniversary.

  • Hourly vs day leave: hourly requires start/end on a single day; day types

use a range and exclude holidays/off-days.

  • Reduced-hours periods (schedule.js:120): later start + shorter day, clamped

to ≥ 0, company-wide vs per-branch precedence.

  • Coverage warning scoping: same department ∩ branch, falls back to branch

if no department, excludes the target, counts APPROVED overlaps only.

Security & tenancy (test as an adversary)

  • Cross-tenant by-id access — every upsert/delete and every referenced FK is

company-scoped. Try to edit/delete another company's department, loan, vacancy, etc. Expect rejection.

  • Personal-data auto-scoping — a non-manager querying leaveRequests,

employeeLoans, timesheet, performanceReviews, etc. is pinned to self. Try to pass someone else's user id → expect notAuthorizedForUser.

  • Masking — as a viewer with only e.g. VIEW_ATTENDANCE, open another

employee's profile: IBAN/national ID/passport/bank/SWIFT must come back null. As the employee themselves, or DO_ALL/MANAGE_HR/MANAGE_SALARY_HUB, they're visible.

  • withUserNames resolves names via a company-scoped in — a foreign id

should resolve to null, not leak a name.

  • Permission gates — verify each mutation/query is behind the permission in the

table in Security & Permissions.

Input validation (Yup + imperative)

Spot-check the limits (inputRules.js): IBAN regex ^[A-Za-z]{2}[0-9A-Za-z]{13,32}$, loan principal/installment 0.01–10,000,000, installment ≤ principal, salary component amount 0.01–1,000,000, job-grade band min ≤ mid ≤ max, ratings 1–5, progress 0–100, GOSI rates 0–50, tenure years 1–40, max daily hours 4–24.

Bulk import

  • bulkUpdateEmployeeComp is per-row independent (not transactional) — one bad

row must not block the rest; each row returns {row, name, ok, error}. Test a mix of valid/invalid rows. Max 500 rows.

  • Departments/positions are auto-created by name and cached per batch — verify

no duplicate department creation within one import.

Cron & notifications

  • Expiry cron (hrExpiryCron.js): fires only on exact milestones (30/14/7/3/1/0

days). Test that a document at 29 or 8 days is not pinged. Only managers with DO_ALL/MANAGE_HR + push tokens get it. Body shows first 4, "+N more".

  • WhatsApp: leave notifications default on, payslip opt-in. A notify failure

must never roll back the triggering action (they're best-effort in try/catch).

Reports

  • Reports render the table shell even with no data for the selected period

(verified live — "No data" is expected, not a bug). Test with a period that has a processed run to confirm numbers populate.

  • Leave liability values remaining paid day leave at base/30; excludes

zero-balance and disabled users.

Regression seams worth a smoke test each release

  • Re-drafting an edited run keeps edits (idempotent on company+year+month).
  • A processed run cannot be cancelled; a Paid line cannot be un-Paid.
  • Mobile parity gaps (documented, not defects): no date-clearing in HR record

form; loan repayment dates to today; no post-upload expiry edit; assign-leave is web-only. Confirm these are still the only mobile gaps.