Payroll Module Overview
Orientation doc for new developers. Payroll spans three repos — web (the brain), Cloud Functions backend (thin), and the mobile app (employee-facing). This is the map; the manual is PAYROLL_LOGIC.md — a 2K-line deep dive into business rules. Read this first; reach for PAYROLL_LOGIC.md when you need rule-level detail.
TL;DR — where things live
- UI + all calculations → src/routes/PayrollNew/
- Backend (settings, tip-out lookup, anomaly detection, auto-close) → functions/modules/payroll/
- Mobile (clock-in/out, cash tips declaration) →
PIVOT-Mobile/src/routes/Attendance/(separate repo) - POS sale ingestion (tips embedded in sales) → functions/integration-engine/ (Toast — new plugin engine) and functions/integrations/ (legacy cron-driven: Veloce, MaitreD, Lightspeed, Clover, MYR, Cluster, Givex, Libro, Nethris-EmployerD)
- Business-rules deep dive → src/routes/PayrollNew/PAYROLL_LOGIC.md
Critical insight: most calculation lives on the web frontend, not the backend. New devs frequently look on the backend for tip math and don't find it. Backend handles persistence + anomaly detection + the auto-close cron; the engine is in
src/routes/PayrollNew/utils/.
End-to-end flow
The numbered walkthrough:
- POS sale arrives. Toast cron pulls orders → toast-sale.plugin.ts maps to a canonical sale (tips live in
CheckTotals.tipTotal) → writes to RTDB. Legacy POS providers follow the older cron-driven pattern under functions/integrations/. - Mobile clock-in/out. Employee declares cash tips in
Attendance/ShiftSummary/index.tsx(mobile, separate repo:PIVOT-Mobile/src/routes/Attendance/ShiftSummary/index.tsx) whenuseIsCashTipsEnabledreturns true. Writes toAttendance/{cid}/{date}/{eid}/{shiftKey}. - Web matches sales to shifts. shiftMatching.ts + salesCalculations.ts use carrier ID + time-overlap. Business-day boundary (sales after midnight assigned to previous day) from businessDay.ts.
- Web computes tips/cuts/hours. PayrollContext.tsx (the central state hub) orchestrates attendanceEnhancement.ts + tipOutGroupHelpers.ts + cutsCalculations.ts.
- Manager edits in UI. Tips/, Cuts/, Hours/ tabs. Manual matches/splits/team-ups persist to
TipsModificationsin RTDB. - Period closes. Manually by manager OR automatically by auto-close-payroll.ts (
AUTO_CLOSE_DAYS = 5). SetsPayrollStatus/{cid}/{date}→ mobile blocks further edits viauseIsPayrollPeriodClosed. - Manager exports. ExportModal/ → format generators in export-formats/ → file download.
Architecture across the 3 repos
| Repo | Role | Volume |
|---|---|---|
| Web (src/routes/PayrollNew/) | The brain. UI + all business calculations + exports. | ~104K LOC, 17 utility files, 8 export formats |
| Backend (functions/modules/payroll/) | Thin: settings persistence, tip-out distribution lookup, anomaly detection, auto-close cron. | 17 files, ~656 LOC |
Mobile (PIVOT-Mobile/src/routes/Attendance/) | Employee clock-in/out + cash tips declaration. Limited math: shift duration + snap-to-schedule rounding only. No tip/cut/export computation. | 4 hooks + 1 modal field |
Key files & structure
Web — src/routes/PayrollNew/
| Folder | Purpose |
|---|---|
Tips/ | Daily/weekly tip entry, modals, activity log UI |
Cuts/ | Tip-pool ("cut") summaries and activity |
Hours/ | Shift / hours management, conflict resolution |
Onboarding/ | Multi-step setup wizard (Hours + Tips/Cuts) |
modals/ | Settings, Export, ConflictShift, etc. |
components/ | Shared UI inside the feature |
utils/ | Calculation engines (matching, tips, cuts, sales, periods) |
export-formats/ | Format generators (Acomba, Payevo, QBO, etc.) |
⚠️ Files >1000 lines — handle with care. Refactor only with the relevant Jest tests green and a manual smoke pass.
- PayrollContext.tsx (3,037) — central state hub; dual-write TODO inside
- Tips/components/DailyTable.tsx (8,035) — daily tip entry grid
- utils/attendanceEnhancement.ts (~78 KB) — shift-enhancement engine
- utils/salesCalculations.ts (~49 KB) — sales aggregation + auto-match
- Hours/
ShiftPopover(2,218), Tips/TipModal(1,976), Onboarding/TipsAndCut(2,110), Cuts/SummaryTable(1,872)
Backend — functions/modules/payroll/
functions/modules/payroll/
├── contracts/ interface files
├── endpoints/apis/payroll.router.ts 4 endpoints
├── handlers/ issues / settings / tips
├── logic/ anomaly-detection · split-settings-patch
├── repositories/ RTDB-only (no PG yet)
├── container.ts wiring
└── index.ts exports
| Method | Path | Handler | Purpose |
|---|---|---|---|
| GET | /payroll/settings | getPayrollSettings | Merged global + period settings |
| PATCH | /payroll/settings | patchPayrollSettings | Routes patch to global vs period RTDB paths |
| GET | /payroll/tip-outs | listTips | Tip-out distribution entries by date range |
| GET | /payroll/issues | listIssues | Anomalies (ghost punches, OT, short shifts, labor variance) |
Cron: functions/cron/auto-close-payroll.ts — closes periods 5 days post-end.
Mobile — PIVOT-Mobile/src/routes/Attendance/
The mobile payroll surface is four hooks in Attendance/hooks.ts — usePeriodAttendanceSettings, useGetAttendanceRoundingTime, useIsCashTipsEnabled, useIsPayrollPeriodClosed — plus the cash-tips field in ShiftSummary/index.tsx. Shift-duration math and snap-to-schedule rounding live in Attendance/utils.ts (getShiftLengthWithoutBreaks, getSnappedStartTime, roundTime). No tip-out math, no callable Cloud Function endpoints — all reads/writes go to RTDB.
Mobile lives in a separate repo. Paths above are not clickable from this doc.
Tip / tip-out calculation logic
A summary; full rules in PAYROLL_LOGIC.md.
- Two ingestion paths. POS-pulled tips (Toast/Veloce/etc embedded in sale records) vs manual entry via
Tips/DailyTable. → PAYROLL_LOGIC.md §2.1, §7. - Salary type drives the flow.
wage_tipsemployees receive tips and cuts;salaryemployees do not. → §3.1. - Cuts pool (tip pooling). Aggregate sales × tip-out % per service period; distributed to back-of-house by hours. Engine in cutsCalculations.ts. → §3.2.
- Tip-out groups. Managers configure groups (role membership + percentage); each group claims a slice of the pool. Engine in tipOutGroupHelpers.ts (~723 lines, has its own test file). → §5.6.
- Reconciliation & overrides. tipsOutReconciliation.ts compares saved vs computed tip-out and flags manual overrides. Audit trail lives in
TipsOutActivityandTipsActivityLog. → §4.4, §5.2.
Six more mechanisms layer on top of the basic flow — non-trivial variance from "sales × percentage":
- Position bonus — fixed amount per position/subposition added before hourly distribution. → §3.3.
- Fixed tip-out — per-employee fixed cut, once per period, regardless of hours. → §3.4.
- Takeout handling — 100% of takeout tips go to the pool without the position's tipOutPercentage discount. → §3.5.
- Team-up & split — one sale split across multiple employees/shifts; mid-shift role changes handled. → §3.6.
- Multi-period shifts — shift spanning periods is assigned to the period with most overlap (not duplicated). → §3.7.
- Exclusions — sales can be tagged to exclude from auto-matching. → §3.8.
For split-modal rounding and edge cases — read PAYROLL_LOGIC.md §3–§5. Don't reinvent these rules; they're load-bearing.
Data model — RTDB paths
| Path | Written by | Read by | Notes |
|---|---|---|---|
Attendance/{cid}/{date}/{eid}/{shiftKey} | Mobile + Web Hours | Web · Backend listIssues | Punches; cashTips field is the mobile contribution |
AttendanceSettings/{cid} | Web Settings | Web · Backend · Mobile | Global payroll config |
PayrollPeriodSettings/{cid}/{effectiveFromDate} | Web Settings | Web · Backend · Mobile | Period-specific (rounding, cash-tips toggle, position salary types) |
PayrollStatus/{cid}/{date} | Web (period close), auto-close-payroll cron | Mobile (locks edits) | Boolean value at path (true = closed) |
TipsOut/{cid}/{date}/{eid}/{shiftKey} | Web | Backend listTips · Web | Tip-out distribution (fixed / role_bonus / sales_tips_out) |
TipsModifications/{cid}/{date} | Web | Web | Manual matches / splits / team-ups |
WeeklySchedule/{cid}/{date}/{eid}/... | Schedule module | Backend listIssues (variance check) | Cross-module read |
AttendanceClaims/{cid}/{date}/{shiftKey} | Web | Web | Conflict tracking for overlapping shifts |
TipsActivityLog/{cid} | Web | Web | Audit trail for manual tip modifications |
TipsMatchExceptions/{cid} | Web | Web | Exceptions for failed sale-to-shift matches |
TipsOutActivities/{cid} | Web | Web | Tip-out distribution change log |
ExtraTipsOut/{cid}/{date} | Web | Web | Carry-forward of unallocated cuts to next period |
EmployeeRates/{cid} | Employees module | Web (read-only) | Rate overrides; not consumed by backend or mobile |
Tips arrive embedded in sales records (Toast plugin pulls net/tax/gross/tip in one shot). There is no standalone "tips" RTDB ingestion path — sales paths are owned by the sales/integrations modules, not payroll.
Settings: global vs period
Settings live in two RTDB paths and the backend's splitSettingsPatch (logic/split-settings-patch.ts) routes a single PATCH to the right place:
- Global (
AttendanceSettings) — 6 keys:payrollFrequency,payrollStartingDay,employeeOrder,customEmployeeOrderIds,exportWeekMode(weekly vs biweekly export grouping),dailySortOrder. - Period (
PayrollPeriodSettings/{effectiveFromDate}) — 13 keys includingroundingTime,snapToScheduleThreshold,declareCashTips,overtimeCalculationMode,tipOutGroups(group definitions live here, not in global),tippingStructure,tipsPaymentMethod,tipOutFrequency,rolesReceiveTipOuts, plus break/threshold tuning (breakIntervals,flagUnder3Hours,lateClockOutThreshold,additionalTimePaid). SeePERIOD_SETTINGS_KEYSinsplit-settings-patch.tsfor the full list.
Why it matters: a global patch affects all history immediately. A period patch updates the latest existing effective-from row in place — a new row is only created when no period exists yet. So changing tipOutGroups mid-period rewrites the current row; it doesn't fork into a new effective-from. Check GLOBAL_SETTINGS_KEYS / PERIOD_SETTINGS_KEYS before adding a setting.
Exports
8 generators in export-formats/, formatted via export-formats/formatters/ (formatAsExcel.ts / formatAsTxt.ts). Entry point: modals/ExportModal/.
| Generator | Target |
|---|---|
generateAcombaExport.ts | Acomba ADX (semicolon-delimited .adx text) |
generatePayevolutionExport.ts | Payevolution (Excel) |
generateQuickBooksOnlineExport.ts | QuickBooks Online |
generateQuickBooksTimeExport.ts | QuickBooks Time |
generate-powerpay-export.ts | PowerPay (XML) |
generateByShiftExport.ts | Generic by-shift |
generateByWeekExport.ts | Generic by-week |
generatePayrollExport.ts | Generic accounting |
Dependencies
Payroll consumes:
- POS sales (sales / integration modules) — read directly from RTDB.
- Schedule (
WeeklySchedule) — for variance detection inlistIssues. - Employees (
Employees/{cid}) — frontend-only read for tip-out group membership + integration IDs (Veloce/Toast/etc) for matching. Backend payroll repos do not touchEmployees/{cid}. - Auth / company state — for permissions and
companyId.
Payroll is consumed by:
- Mobile — reads
PayrollPeriodSettingsandPayrollStatus, writescashTips. - Export-formats — every formatter consumes data through
PayrollContext. - HeadOfficeDashboard —
export-payrollreadsPayrollPeriodSettingsfor multi-location exports. - OwnerDashboard — feature flags gate payroll features (
isPayrollTabsEnabled,hasAccessToAttendance).
Cross-module communication style: synchronous HTTP (frontend → backend) + direct RTDB reads (mobile, frontend). No Pub/Sub events — sales arrive via cron pull, period close is a silent RTDB write.
Known issues & fragile areas
- 🚩
PayrollContext.tsxdual-write — TODO: "Remove dual-write once cloud functions + mobile read from PayrollPeriodSettings". Migration in flight; touching either side without the other causes drift. - 🚩
DailyTable.tsx(8K lines) — daily tip entry grid carries quiet invariants encoded across rules in PAYROLL_LOGIC.md §5. Refactor only with green tests + manual smoke. - 🚩 Tips/Cuts/Hours code duplication — same calculation appears in
Cuts/components/SummaryTable.tsxand elsewhere; TODO logged, not yet unified. - 🚩 Calculation lives on the frontend — closing a period or running a re-export depends on client compute. Backend can't easily take over without a significant refactor.
- 🚩 RTDB → Postgres migration — backend payroll repositories are still all
Rtdb*. Frontend reads RTDB directly in many places. No agreed cut line yet for which module owns which path. - 🚩 No Pub/Sub events — period close, settings changes, cash-tips writes are silent. Cross-module consumers (notifications, etc.) must poll or piggyback on RTDB triggers.
- 🚩 No backend tests under
functions/modules/payroll/. Test safety net is frontend Jest (utils/__tests__/,export-formats/__tests__/) + Playwright e2e. The auto-close cron has 1 test. - 🚩 Heterogeneous POS legacy integrations — Toast uses the new plugin engine; Veloce / MaitreD / Lightspeed / Clover / MYR / Cluster / Givex / Libro / Nethris-EmployerD are cron-driven snowflakes under functions/integrations/. Tip-data shape varies subtly per provider.
- 🚩 Auto-close cron lacks transaction isolation — auto-close-payroll.ts does a bulk RTDB
update()with no transaction wrapper. If a manager closes manually at the same instant the cron fires, RTDB last-write-wins (no data corruption, but ordering may surprise). - 🚩 Hard-coded
America/Torontotimezone in auto-close-payroll.ts — period-end detection will drift for non-Toronto companies if Pivot ever supports other regions.
Testing
- Frontend Jest — 17 test files total. Tests sit beside source files (not under
__tests__/for utils):utils/*.test.ts(9):businessDay,shiftMatching,periodHelpers,getClosestPayPeriodStart,autoMatchPersistence,summaryCalculations,tipOutGroupHelpers,tipsConflicts,tipsOutReconciliation.export-formats/__tests__/*.test.ts(3): PowerPay, ByShift, generic Payroll.export-formats/formatters/__tests__/(2) +export-formats/utils/__tests__/(1).Hours/utils/shiftValidation.test.ts(1) +Onboarding/TipsAndCut/utils/__tests__/(1).- ⚠️ Acomba, Payevolution, QBO, QB Time generators have no tests.
- Run the relevant test before refactoring
tipOutGroupHelpers,cutsCalculations, or any export generator.
- Backend — only functions/cron/tests/auto-close-payroll.test.ts. None for the 4 payroll endpoints (settings, tip-outs, issues).
- Playwright e2e in e2e/ covers auth / schedule / navigation. No dedicated payroll specs today — only navigation guards touch payroll routes tangentially.
- No mobile tests for the cash-tips path or any Attendance/ShiftSummary flow. (Mobile-wide untyped surface: ~107
\bany\b+ 8@ts-ignoresites inPIVOT-Mobile/src/.)
Where to start when…
| Your ticket is about | Start here |
|---|---|
| Tip entry UI bug | Tips/components/DailyTable.tsx + TipModal/ |
| Tip-out math | PayrollContext.tsx (orchestrator — invokes helpers) → utils/tipOutGroupHelpers.ts (engine) + PAYROLL_LOGIC.md §3.2, §5.6 |
| Sales not matching | utils/shiftMatching.ts + salesCalculations.ts + PAYROLL_LOGIC.md §2 |
| New POS provider | functions/integration-engine/domains/pos/providers/ (use Toast as template) |
| New export format | export-formats/ + export-formats/formatters/ (no test fixtures; see generatePayrollExport.test.ts for mock setup pattern) |
| Settings missing on a period | UI bug → modals/SettingsModal/. Global-vs-period routing → logic/split-settings-patch.ts (check key in correct list). |
| Mobile cash tips | Data/persistence bug → PIVOT-Mobile/src/routes/Attendance/hooks.ts (useIsCashTipsEnabled). Display/UI bug → PIVOT-Mobile/src/routes/Attendance/ShiftSummary/index.tsx. |
| Period not auto-closing | functions/cron/auto-close-payroll.ts |
| Anomaly false positive | logic/anomaly-detection.ts |
| Period not showing in UI | components/HeaderCard/ + utils/periodHelpers.ts (period math) + businessDay.ts (boundary) |
| Tips from POS look wrong | Toast mapper in toast-sale.plugin.ts (tip extraction) → utils/salesCalculations.ts (aggregation) |
| Employee missing from payroll | PayrollContext.tsx employee filtering + Employees/{cid} salary type (wage_tips vs salary) |