Feature #148
open[HR][P10] Payroll engine (before the flag flips)
0%
Description
Process guard (stop double-pay on reprocess), unique period, attendance/leave-derived paid days, run scope, two-step run UI with confirmation, string status, payslip detail route, real YTD. Not urgent while the portal flag stays off and P1 has gated the API; required before Payroll comes off the flag.
Size: L
Needs owner decision #9, 20 (see #137).
Source: clinq-docs/modules/hr/reviews/2026-09-28-ui-audit/ (branch docs/hr-ui-audit-2026-09-28)
Findings covered:
- D-02
- D-03
- D-04
- D-05
- D-06
- D-07
- D-08
- D-09
SV Updated by Sravan V S about 2 months ago
- Assignee set to Sravan V S
S- Updated by SK - 12 days ago
- Status changed from New to Resolved
- Resolved By set to SK -
Fixed and merged to dev on nuvita-hr (51a5d83, rebased cleanly onto #147's tip - rebuilt and re-ran every suite after the rebase before merging, per this program's established practice of never trusting a clean textual rebase on its own). Worked from the ticket's terse description, verified each item against current code first (source audit doc confirmed permanently lost, see #137).
What was real and fixed:
- Process guard (double-pay on reprocess): confirmed real - a second Process call re-ran the whole payroll loop with no guard. PayrollRun.MarkProcessing() now only allows the transition from Draft; a second attempt is rejected (422).
- Unique period + run scope (owner decision #9): confirmed real and more severe than described - PayrollRun had no HospitalId at all, so one run paid every active employee in the entire tenant regardless of which hospital triggered it. Added HospitalId (resolved from the existing hospital-context infrastructure #139 built), a duplicate-period guard in code plus a matching unique DB index as backstop.
- Attendance/leave-derived paid days (owner decision #20): confirmed real - LOP was hardcoded to zero, so every employee was paid as if they worked every calendar day. Now computed from real attendance (Absent) and approved leave against unpaid leave types, reusing the #140/#146-corrected ledger rather than a new independent calculation. A data gap (no attendance record, or OnLeave with no matching leave request) defaults to paid - a deliberate conservative choice, documented in code.
- String status: confirmed real (bare int over the wire, violating the platform convention) - fixed, with a migration that explicitly maps 0/1/2 to Draft/Processing/Completed rather than naively stringifying the digit.
- Payslip detail route: confirmed real - the ESS portal already had a working link and page pointing at an endpoint that simply didn't exist (a live-looking 404). Added it.
- Real YTD: confirmed real - YTD summed payslips with no fiscal-year boundary, and PF/TDS YTD were hardcoded placeholders. Both now real, FY-scoped.
- Already fixed by earlier tickets, verified not re-touched: API gating while the Payroll flag is off (already done in #139), and the two-step run UI with ConfirmDialog (already done in #142).
Flagged, not fixed (genuinely out of scope without more product input, not guessed at): overtime pay still isn't computed from attendance overtime minutes (needs an OT-rate design this ticket didn't specify). Half-day attendance doesn't reduce paid days (kept fully paid - the safer default on an ambiguous case).
Deploy gate - needs your sign-off before running on real data: the migration converts the Status column from int to text (verified correct against seeded legacy-shaped rows - 0/1/2 to Draft/Processing/Completed, confirmed manually) and adds a nullable HospitalId + unique index (pure schema, zero data risk). Existing payroll runs are deliberately left HospitalId = NULL (meaning unscoped/tenant-wide-visible, unchanged from today) - no attempt was made to guess which hospital an already-processed, money-moving historical run belongs to; a wrong guess there would misattribute a financial record. Same pattern as #140/#146's migrations - assigning to you for that sign-off before this runs against any real tenant.
Test results on the merged dev tip: dotnet build clean; Nuvita.Hr.Tests.Unit 1338/1338 passed; Nuvita.Hr.Tests.Integration 261/261 passed; portal vitest 810/810 passed across 104 files; portal tsc --noEmit clean.
QA change-log entry added.