Salary Hub & Payroll
Business view
Salary Hub is the manager-facing side of payroll: a monthly run that pays every employee, doctor commission calculated automatically from completed procedures, and a self-service "My Income" view for each employee.
- Payroll runs happen once a period, either automatically or with a manual review step — the clinic chooses. A run has one line per employee, and for hourly/attendance-tracked staff it factors in worked hours, overtime, lateness, absence, and leave deductions.
- Commission policies decide how a doctor's commission is calculated: based on production (work completed) or on payment (money actually collected), with optional deductions for materials, lab costs, discounts given, or tax. If a clinic never sets a policy, doctors get the same commission calculation the clinic already had before this module existed — nothing changes underneath them.
- Doctor payouts (found under the Accounting section, not Salary Hub — historically doctor pay was closer to a supplier payment than a payroll line) settle what's owed to a doctor: it pays down any accrued, unpaid commission first, then whatever's left of the payout goes to base salary.
- My Income is what an employee or doctor sees for themselves: base salary, outstanding commission, this month's commission activity, leave balance, and payslip/payout history. It only ever shows the logged-in user's own numbers.
Technical view
Data model
packages/prisma/schema.prisma:3782-4045. Key enums: PayrollMode (AUTO | MANUAL_REVIEW), PayrollRunStatus (DRAFT | PENDING_REVIEW | CONFIRMED | PROCESSED | CANCELLED), PayrollLineStatus (PENDING | PAID | SKIPPED), PayType (MONTHLY_FIXED | HOURLY), CommissionBasis (PRODUCTION | PAYMENT).
PayrollSettings(:3853-3869) — one per company:mode,payDayOfMonth,autoConfirm,defaultTreasuryId.CommissionPolicy(:3876-3894) — scoped by company/branch/doctor (most-specific-first),basis, four deduction flags (deductMaterials,deductLab,deductDiscount,deductTax).SalaryProfile(:3929-3962) — one per user,baseSalary,payMethod, plus a full HR compensation block:payType,contractedHoursPerMonth,hourlyRate,overtimeMultiplier(default 1.5),latenessGraceMinutes(default 10),annualLeaveDays(default 21),accrualEnabled/accrualInterval.PayrollRun/PayrollRunLine(:3965-3987, :3991-4025) — one run per company per period, one line per employee, tracking scheduled vs. worked hours, overtime, deductions, gross/net amount, and (once processed) a settlement reference — aDoctorPayoutfor doctors, anExpensefor everyone else.WorkSchedule(:4028-4045) — per-user working days/hours, either inherited from the branch, offset from it, or fully custom.
The schema's own comment on the Company model clarifies the design intent: "Commission RATES stay on User/DoctorPercent (the accrual engine reads them); Salary Hub only relocates their editing UI." In other words, this module didn't replace the existing commission-rate storage — it added policy and workflow on top of it.
Commission calculation
packages/server/src/resolvers/mutations/mutationUtils/commissionEngine.js — resolveCommissionPolicy looks up the most specific applicable policy (doctor+branch → doctor → branch → company); with no policy configured at all, it falls back to a virtual default (PRODUCTION, no deductions) that reproduces the pre-existing legacy calculation byte-for-byte. computeCommission optionally deducts materials/lab/discount/tax from the base price before handing off to the existing rate ladder (doctor's percentage resolved through a precedence chain of price-list/step/procedure settings, unchanged by this PR). Whether commission accrues at the moment of the procedure (PRODUCTION) or waits until payment is collected (PAYMENT) is decided per the resolved policy.
Commission books as a SalaryAdjustment, posted Dr Commission Expense / Cr Doctor Payable (packages/server/src/accounting/posting/salary.js:9-13,19-36). A doctor's outstanding commission is always read live off the GL balance of their Doctor Payable role account — never a cached counter.
Mutations: upsertCommissionPolicy (ledgerMutations.js:1025)/deleteCommissionPolicy (:1059), gated MANAGE_SALARY_HUB. Screen: salaryHub/CommissionPoliciesScreen.js; reporting: salaryHub/CommissionReportScreen.js.
Doctor Payouts
packages/server/src/resolvers/mutations/actions/payroll/createDoctorPayout.js — performDoctorPayout settles min(payout amount, outstanding commission) against Doctor Payable first, then books whatever's left of the payout as a base-salary expense — deliberately not a generic Expense, to avoid double-counting commission that's already been recognized. Reused by both the standalone payout mutation and by payroll-run processing.
Interesting detail: doctor payouts share the legacy salary-adjustment permission family (ADD_SALARY_ADJUSTMENT/DELETE_SALARY_ADJUSTMENT) rather than a dedicated payroll permission — intentional, since they operate on the same doctor-compensation subledger the older salary-adjustment feature already used.
Payroll runs
packages/server/src/accounting/payroll/payrollRun.js — draftPayrollRun builds one line per active, includeInAutoRun salary profile; confirmPayrollRun locks it for processing; processPayrollRun pays every line (doctors via performDoctorPayout, everyone else via a salary Expense), guarding against double-payment on retry with a PAYROLL_LINE_ALREADY_SETTLED sentinel. A daily cron (packages/server/src/cronJobs/payroll/payrollCron.js) checks each company's payDayOfMonth; in AUTO mode with autoConfirm it drafts, confirms, and processes the previous month's run without human involvement, otherwise it stops at draft for manual review.
Screens: salaryHub/SalaryHubScreen.js (People / Payroll / Settings tabs), salaryHub/PayrollRunDetailScreen.js (per-line detail with an editable net amount and skip/include toggle while a run is still open), salaryHub/MyIncomeScreen.js (self-service, hard-scoped server-side to the logged-in user's own id — it never accepts a user argument). Permissions: viewSalaryHub, manageSalaryHub, processPayroll, viewOwnSalary.
Gaps to know about
PayrollSettings.accrueBaseSalaryexists in the schema (intended to accrue salary expense monthly rather than only at payout) but nothing in the payroll engine reads or branches on it — a stub for a not-yet-built accrual mode.CommissionBasisis a real database enum but exposed to GraphQL as a plainString, manually validated server-side against the two allowed values — a minor schema/API mismatch, not a functional bug.