Status: 🟢 Live & real — with one honest asterisk. The Team & HR pillar is fully wired and
shipped: a real workforce directory, PIN login, geofenced time clock (staff + public crew), a
payroll register computed from clock data, working exports (CSV/XLSX), a balanced Journal
Entry posted to QuickBooks Online (verified against the live sandbox), certified-payroll print,
PTO policies + balances, performance-review cycles, compliance/COI, and capacity planning. The
asterisk: payroll is a labour system of record + export/GL post, not a payroll engine —
it never computes gross-to-net, cuts a cheque, or files a tax. See § Is this a real payroll
run?.
Audience: GoBuild staff (eng, ops, sales) + integrators. Code-grounded to a single hub.
This is the reference companion to the Team & HR Command Center hub page.
Badge legend: 🟢 Live & real · 🟡 Works but partial / config-gated · ⚪ Dormant / scaffolded · ❌ Not built (deliberate non-goal or vaporware)
House terms: a Job is the construction project (app/models/job.py); a Client is the
customer you sell to. This pillar is about your workforce — staff, subcontractor crews, and
vendors — not Clients. Leads/Clients deliberately live in Sales, not here
(app/services/team_hub.py:5).
Everything below lives in one command center: GET /portal/team-hub
(app/portal/router.py:4052), assembled by app/services/team_hub.py (assemble(), ~20 data
blocks → 16 Alpine panes in templates/team_hub.html). Guard: require_portal_role(pm, office) +
owner; the field role is excluded (field staff live in the PWA at /field and appear here only
as data).
| Feature | Badge | Where |
|---|---|---|
| People / workforce directory | 🟢 | app/services/team_hub.py (directory), person detail app/routers/people.py:202 |
| Roles & access (invite/role/toggle/remove) | 🟢 | folded into the Team pane; /portal/team/{id}/role\|toggle\|remove |
| PIN field login | 🟢 | app/routers/pin.py:67 (login), pin.py:274 (admin dashboard) |
| Time clock — staff (in-app, geofenced) | 🟢 | app/routers/field.py:1329-1414 |
| Time clock — public crew (tokenized, no login) | 🟢 | app/routers/timeclock.py (/clock/{token}) |
| Timesheet entry grid + approval | 🟢 | app/portal/router.py:3325 (grid), 3657 (approve) |
| Payroll register (weekly, computed) | 🟢 | app/services/payroll.py:43 |
| Jurisdiction-aware OT (US FLSA + CA provincial) | 🟢 | app/services/overtime.py |
| Payroll export — CSV / XLSX | 🟢 | app/portal/router.py:3442 |
| Payroll → QBO Journal Entry (+ TimeActivity) | 🟢 | app/services/quickbooks_sync.py:355, route router.py:3477 |
| Certified payroll print (WH-347 / fair-wage) | 🟢 | app/routers/payroll.py:82, templates/payroll_print.html |
| PTO policies + accrued/taken/remaining balances | 🟢 | app/services/pto.py, models app/models/hr.py:116 |
| Performance reviews (templates, cycles, self+mgr) | 🟢 | app/models/hr.py:147-232, app/services/performance.py |
| Compliance / COI + credential expiry radar | 🟢 | folded into hub; COI auto-chase reused from app/services/coi.py |
| Capacity / workload planner | 🟢 | app/services/capacity.py:95 |
| Employee self-service ("My HR") | 🟢 | staff app/routers/field.py:707; crew app/routers/sub_portal.py:218 |
| Payroll processing (gross-to-net, cheques, direct deposit) | ❌ | deliberate non-goal — integrate, don't build |
| Payroll tax filing (W-2/T4, remittances) | ❌ | deliberate non-goal |
| Benefits admin / ATS / LMS / benchmarking | ❌ | deliberate non-goal (docs/adp_wfn_gap.md) |
No — and that is the design, not a shortfall. GoBuild is the labour system of record, not a
payroll engine.
Shift rows × rates.app/services/payroll.py:43-123). It thendocs/payroll_to_qbo_plan.md:26-28). Employer taxes are explicitly out of scopequickbooks_sync.py, docs/payroll_to_qbo_plan.md:87-89).So the honest positioning: GoBuild produces the numbers and posts the cost; a real payroll
provider (or bookkeeper) cuts the actual pay. The "estimated earnings" a worker sees in self-serve
is labelled estimate · pre-tax · not a pay stub for exactly this reason.
The directory is the workforce half of a deliberately split contacts directory: Sales owns
Leads/Clients; Team & HR owns staff · crews · vendors over a shared Person spine
(docs/people_hr_module.md:8-23). Three populations, one identity model:
| Population | Backed by | Role in the hub |
|---|---|---|
| Team (staff/employees) | User + StaffProfile (app/models/hr.py:19) |
roster, employment record, certs, pay, PTO, reviews |
| Crew (subcontractors) | Sub (company, trade, rate, clock_token, person_id) |
roster, workload, COI, sub-portal + clock links |
| Vendors (suppliers) | Vendor |
roster, scorecard, insurance, T5018 |
The person profile is role-adaptive — a staffer shows Employment + Time-off; a crew shows
Credentials + RSVP; a vendor shows Scorecard + Insurance (app/routers/people.py:202-250). Staff
are first-class people: User.person_id links auth to identity, and HR data lives on the separate
StaffProfile table so User stays auth-lean (app/models/hr.py:1-50). The org chart is derived
from StaffProfile.manager_id via DFS in the context builder.
The route is guarded to owner / pm / office; field is excluded. Within that:
app/portal/router.py:3486)./field and surface here as directory rows, clockInvite / role-change / activate-deactivate / remove fold into the Team pane, reusing the
existing /portal/team/{id}/role|toggle|remove routes (docs/team_hr_native_pages.md:53-56).
A numeric-PIN quick sign-in for the field app — a numpad instead of email + password
(app/routers/pin.py). Three shapes, chosen from a signed keystone_device cookie:
pin.py:131).pin.py:171).pin.py:93). This is theSecurity model: a PIN is never a primary credential off a trusted device — you set it once while
already signed in, and it only unlocks devices holding the cookie. 5 wrong tries → 15-minute
lockout and fall back to email + password (pin.py:106-128, LOCKOUT_TRIES = 5). PINs are stored
as a hash; only a 4-digit numeric PIN is accepted.
Admin dashboard (owner/pm, pin.py:274, the "Field access" page): set/generate/clear/deliver a
PIN per member, bulk-generate for everyone missing one, and email/text it via the org's own Resend/
Twilio. Because only a hash is stored, an existing PIN can't be re-sent — "deliver" rotates it
(pin.py:450). The same page lists each crew's tokenized sub-portal share link.
⚪ Adoption is not measured in-code. There is no telemetry on how many orgs enable PINs or how
often the numpad is used vs. email/password. See open questions.
Two clock surfaces, both writing the same Shift model (app/models/timeclock.py) so hours
flow into one register:
app/routers/field.py:1329-1414.user_id, GPS lat/lng, and whether the punch was in the Job's geofence (Haversine vsgeofence_radius_m, default 200 m).app/routers/timeclock.py, reached at/clock/{token} from a per-crew link. The crew picks a Job, the browser supplies GPS, andtimeclock.py:106-116).Entry grid + approval — beyond punches, a spreadsheet-speed Timesheets grid keys a whole
week at once (rows = worker × Job, columns = Mon–Sun; router.py:3325 data, 3342 save). It
writes Shift rows with source='manual' (clocked punches are source='clock' and shown locked/
grey so hours never double-count — docs/payroll_grid_plan.md:28-34). Grid entries by owner/PM are
auto-approved (router.py:3346). A separate approve toggle sets Shift.approved_at /
approved_by_user_id (router.py:3657). When the payroll_require_approval pref is on, the
register counts approved hours only (app/services/payroll.py:58-59).
payroll.build_register(db, org_id, week_start, *, job_id, prefs) (app/services/payroll.py:43) is
the heart. It's stateless — no payroll tables, computed on the fly:
Shift rows for the Monday→Sunday week (optionally one Job); filter topayroll_require_approval is set.sub_id / staff user_id / bare name), summing per-day hours._rate_and_class, payroll.py:29-41): a crew usesSub.hourly_rate; a staffer uses their per-person StaffProfile.pay_rate_cents when hourly, elselabor_rate.gross = reg·rate + ot·rate·1.5.kind = employee | contractor — this decides where the hour goes downstream:payroll.py:95-104).Overtime is jurisdiction-aware 🟢 (app/services/overtime.py). The old hardcoded
OT_THRESHOLD = 40.0 (still present at payroll.py:20 as the FLSA baseline constant) is superseded
by a per-province rules table: weekly and daily thresholds, e.g. ON 44 h/wk, BC 40 h/wk + >8 h/day,
AB "greater of daily/weekly" (overtime.py, CA_RULES). Caveats in-code: double-time is not
modelled (everything over threshold is 1.5×), and US per-state daily OT (e.g. CA >8/day) is a
future add. There is no per-worker country — the org-level country/region pref drives it.
The register also surfaces a standalone Finance page at /portal/payroll
(app/routers/payroll.py:61), which is require_feature("payroll")-gated — distinct from the hub's
Payroll pane.
Three real outputs off the same register:
GET /portal/team-hub/payroll/export (app/portal/router.py:3442). Two files via
app/services/payroll_export.py:
Provider-agnostic (imports into any payroll tool or a bookkeeper). Defaults to employees only
(scope=all includes contractors); external_payroll_id on StaffProfile/Sub lines a worker up
with the payroll system's employee id (app/models/hr.py:35).
quickbooks_sync.push_payroll_journal(db, org_id, week_start) (app/services/quickbooks_sync.py:355),
triggered by an owner-only "Post to QuickBooks" button
(POST /portal/team-hub/payroll/post-qbo, router.py:3477). It:
ClassRef for job costing), balanced by one credit to theQuickBooksMap (payroll_je),docs/payroll_to_qbo_plan.md:4);quickbooks_sync.py:448, run async at router.py:3502).The button is disabled until both accounts are mapped and a QBO connection exists — no silent
mis-post. This is exactly how Gusto/ADP integrate with QBO; QBO has no API to write a payroll run,
so a JE is the correct ceiling (docs/payroll_to_qbo_plan.md:14-28).
GET /portal/payroll/print (app/routers/payroll.py:82) → templates/payroll_print.html,
country-aware (_payroll_meta, payroll.py:51): US Form WH-347 (DOL wages & hours) or a
Canadian provincial fair-wage schedule. This is the prevailing-wage/Davis-Bacon record — one of
the construction wedges over ADP.
Real leave balances, not just request/approve (app/services/pto.py, models app/models/hr.py):
PtoPolicy (hr.py:116) picks the accrual math via model: accrual = US-style hoursrate in hours, carryover cap) orstatutory_ca = Canadian statutory vacation pay (rate = 4%/6% of gross). The org countrypto.ensure_default_policy). (pto.accrued_hours, taken_hours_ytd, PtoAdjustmentathr.py:134`).employment_type == 'contractor' staffhr.py:117-120,docs/close_adp_gap_plan.md:113-155). Crews still get a sub-portal self-serve unpaid time-offapp/routers/sub_portal.py:218).TimeOffRequest (hr.py:94) carries subject_kind = team|crew, a date range +hours, and status pending/approved/denied. Approval is manager-first, owner-fallbackStaffProfile.manager_id; crews have no manager so always route to owner).A full review layer (app/models/hr.py:147-232, app/services/performance.py): reusable
templates of questions (ReviewTemplate/ReviewQuestion), company cycles ("2026 H1",
ReviewCycle) that spawn a PerformanceReview per employee, and self + manager parties with
separate ratings/statuses and per-question ReviewAnswer rows. States run draft → shared →
acknowledged. The hub renders per-cycle progress (self vs manager submitted) plus a flat review
history. This is well beyond the "ratings + goals" MVP.
The people side of the compliance hub folds in as a pane: licenses, insurance/COI, and staff/
crew/vendor credentials with an expiry radar (90-day scan bucketed expired/30/60/90 in the
context builder). Credential auto-chase reuses app/services/coi.py — the same engine that
chases subcontractor certificates of insurance. Tracking a subcontractor's COI is pure
construction differentiation ADP doesn't do at all (docs/adp_wfn_gap.md:46-49). Note: GoBuild
tracks cert expiry; it does not deliver training (no LMS — ❌, deliberate).
app/services/capacity.py:95 (workload) buckets each incomplete, crew-assigned ScheduleItem's
effort across its working days (skipping weekends + stat holidays via holidays.py) into a
crews × 8-weeks heatmap, flagging weeks over crew_weekly_hours (default 40). bucket_rows is
shared with the planner's dry-run simulator so the live preview and saved view always agree. The
hub's Workload pane is native (not an iframe) and reuses the classic planner's preview/commit/advice
JSON endpoints, including a "Plan my week" AI panel (docs/team_hr_native_pages.md:44-51).
Framing: ADP Workforce Now is a full HCM system of record (payroll processing + tax filing +
benefits + talent). GoBuild is a workforce-management & labour-costing layer for construction
(docs/adp_wfn_gap.md). The gap analysis split into three buckets — and the plan
(docs/close_adp_gap_plan.md) shipped the cheap vertical wins:
app/services/team_hub.py), so the four-phase close-the-gap roadmap isdocs/close_adp_gap_plan.md:189-192).Bottom line: closing the ADP gap was planned and largely executed for the cheap/vertical
gaps; the system-of-record gaps are intentionally out of scope and remain ❌ by design.
docs/people_hr_module.md, docs/team_hr_native_pages.md, docs/payroll_grid_plan.md,docs/payroll_to_qbo_plan.md, docs/adp_wfn_gap.md, docs/close_adp_gap_plan.md.app/services/{payroll,capacity,pto,performance,quickbooks_sync}.py,app/routers/{people,payroll,timeclock,pin,safety,field,sub_portal}.py, app/models/hr.py,app/services/team_hub.py.