feat(payroll): Gusto rates import, hours export, period lock, crew bug reports (pipeline v2 phase 5) - #441
feat(payroll): Gusto rates import, hours export, period lock, crew bug reports (pipeline v2 phase 5)#441Clarion1631 wants to merge 46 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Required changes:
VERDICT: REQUEST_CHANGES |
VERDICT: REQUEST_CHANGES |
|
This is not safe to merge.
VERDICT: REQUEST_CHANGES |
… plan Both were untracked working-tree files. Committing them first so the implementation commits that follow have their spec in history. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
User.lastRateSyncAt records when a member's pay rate was last CONFIRMED (Gusto CSV import or a manual edit) — a staleness marker, not a change log. PayrollPeriod stores a reviewed/exported pay period as a half-open [periodStart, periodEnd) range plus lockedAt / lockedById / exportHash. Unlock clears lockedAt but keeps the row and hash, so "this is what we exported" survives an unlock. Additive + idempotent in both the migration and the apply script; the two are kept statement-for-statement in sync. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- src/lib/payroll-config.ts: pay-period length, week start and salaried-email list as env-overridable DEFAULTS, clearly labelled as pending Justin's decision (spec section 7 risks 1-3). PAYROLL_WEEK_START moves the pay period only; WA overtime stays a Mon-Sun workweek property in overtime.ts. - src/lib/payroll-period.ts: one lock rule (half-open range, lockedAt only), used by PUT /api/time-entries and PATCH/DELETE /api/time-entries/[id]. PATCH checks BOTH the stored and the new startTime, so moving a punch INTO a locked period is refused too. 423 PERIOD_LOCKED. - src/lib/pay-rate-guard.ts: $0-rate block. 422 ZERO_RATE_BLOCKED, entry stays OPEN, worker-facing message on the crew path and a manager-facing one on the manager close. ADMIN/FINANCE exempt (salaried). - src/lib/gusto-export-core.ts + gusto-export-db.ts: per-employee summary and per-entry detail CSVs. Overtime comes from overtime.ts and paid hours already exclude the meal deduction — neither is re-derived here. Full workweeks are fetched so a period opening mid-week splits OT correctly. - GET /api/time-entries/export/gusto replaces the ungated per-entry /api/gusto/export (deleted). ADMIN or financialReports, web-only, 409 while any in-range entry is open or flagged. The DEFERRED-day settlement preamble came across and is skipped entirely for a locked period. - /manager/time-entries: export button repointed at the review page; red "No pay rate" badge on rows whose owner would be blocked at clock-out. - Help widget: both submit routes accept any ACTIVATED staff role via authenticateMobileOrSession, and are allowlisted (exact) in src/proxy.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ll-export - src/lib/rate-import.ts: pure Gusto-export parser + matcher. Email first, exact full name as a fallback, never fuzzy — writing a pay rate onto the wrong person is worse than an unmatched row. Ambiguous or duplicated rows are left unmatched on purpose. hourlyRate only; burden is not in the export. - previewGustoRateImport / applyGustoRateImport server actions: preview writes nothing, save re-validates its own argument (a server action argument is an HTTP body, not the preview's return value) and writes in one transaction. - lockPayrollPeriod / unlockPayrollPeriod: exportHash is recomputed server-side at lock time; lock refuses while anything in range is open or flagged; unlock is ADMIN-only and keeps the row and hash. - Company -> Team Members: Payroll rates panel (hourly, burden, last synced in red when never or over 90 days) + Import CSV preview-diff modal. - Manual rate edits now stamp lastRateSyncAt — the column means "last confirmed", so re-saving the same number is a confirmation. - /manager/payroll-export: period picker, per-employee Regular/OT/Double OT totals, blocking-entry banner, both downloads, lock/unlock, and a stored-vs-live summary hash comparison. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… widget - tests/gusto-export.test.ts: the six OT cases from spec section 6 (exact 40h, a 41st hour split inside its entry, a workweek straddling periodStart, a meal-deducted 9h shift paying 8.5, two projects one day, Sunday-to-Monday midnight attributed to the week it started in), the 409 readiness rule, the DEFERRED settlement plan (including "locked means no writes"), and byte-compared golden summary/detail CSVs. - tests/payroll-period-lock.test.ts: half-open boundaries, 423 body, an edit MOVING an entry into a locked period, unlock, and the PUT route refusing without touching the row. - tests/zero-rate-clockout.test.ts: 422 ZERO_RATE_BLOCKED with the entry left OPEN, the manager-facing variant, ADMIN exempt. - tests/rate-import.test.ts: parser edge cases, email-then-exact-name matching, ambiguous and duplicated rows refused, plus the payroll-config defaults. - tests/help-chat-bug-widget.test.ts: the role/status matrix and the exact proxy allowlist, including the Server-Action-dispatch refusal. .gitattributes marks tests/fixtures/*.csv as -text so a Windows checkout cannot rewrite the golden files' line endings. PATCH/DELETE on /api/time-entries/[id] are not DI-factored, so their 423 and the PATCH $0-rate mirror are covered by the shared pure helper plus a source-level wiring tripwire, labelled as such in both test files rather than passed off as behavioural coverage. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The three HUMAN DECISION items in section 7 are now env-overridable defaults in src/lib/payroll-config.ts rather than open questions blocking the build, and the Gusto CSV headers are one constant each. Documented in the spec and in .env.example so the next reader does not go looking for a decision that was already parked in code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…(B1) The first cut gated three API routes and left four server actions wide open: time-expense-actions updateTimeEntry (writes startTime AND durationHours) and both deletes, the projects/[id]/timeclock create/update/delete, and createTimeEntryCore — where creating hours AT a date is moving hours INTO that period. All now call the new throwing variant assertPeriodUnlockedOrThrow; a returned value would have been ignored by those callers. POST /api/time-entries is gated too: startTime is client-supplied there, so a clock-in could land in a locked period and leave a punch that can never close. TOCTOU: the check is not a transaction, so PUT and PATCH now re-check immediately before the write instead of trusting a decision made several awaited round trips earlier. The canonical writer list, and the metadata-only writers deliberately left ungated (flags, notes, cost coding, CO tags, billing stamps), are documented in payroll-period.ts and pinned by a tripwire test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d (B2)
The allowlist version ("block FIELD_CREW and MANAGER") failed open for any role
added later, and blocked salaried MANAGERs whose $0 hourly rate is CORRECT — CJ
and Richard could never have clocked out, and nothing sweeps a stuck punch.
Inverted: block unless positively known salaried (role ADMIN/FINANCE, or an
email on the payroll-config list). email is now threaded through
findOwnerRates, the PATCH mirror, and both badge surfaces.
/api/users resolves `salaried` server-side rather than letting the client
component re-derive it: PAYROLL_SALARIED_EMAILS is env-configurable and a
browser bundle cannot read it, so a client-side copy of the rule would ignore
an override and paint a false badge.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ock race
B3 — an unsettled DEFERRED day exported at FULL pay. The open-punch guard was
company-wide and time-unbounded, so anyone clocked in right now suppressed
settlement of their own DEFERRED day weeks earlier. Scoped to the DAY being
settled, and anything still DEFERRED inside the period now BLOCKS (409) instead
of exporting with no meal deducted.
M3 — a CLOSED entry with no hours is dropped by the totals, so it now blocks
too ("zeroDuration"). A silently missing shift is a silently missing wage.
M2 — csvField defuses formula leads (= + - @ tab CR LF) with a leading
apostrophe. Quoting alone does not help: the spreadsheet strips the quotes and
then evaluates what is inside, and this file is opened by a bookkeeper.
M1 — lockPayrollPeriod is no longer check-then-act. Readiness and settlement run
first, then ONE transaction takes the lock and recomputes through the tx client;
disagreement throws and rolls the lock back. Taking the lock BEFORE the
recompute is the point: once lockedAt commits, every gated writer is refused.
M4 — the hash covers BOTH csvs (two different entry sets can round to identical
per-employee totals), employees sort with an id tiebreaker, both findMany calls
are ordered, and the page copy now says what the hash actually covers.
Minor — the 62-day cap applies to the lock action and the page, day keys use the
resolved company time zone instead of the hardcoded LA one, and the
settleDay-from-a-GET behaviour is documented as intentional and idempotent.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s are refused (M5) A NAME match displayed the CSV's email next to a rate that would land on whoever shares the name — the human approving the preview had no way to see the mismatch. The matched member's email now wins, name-only matches carry a visible badge, and they start UNTICKED so somebody has to look. parseRateValue refuses a comma instead of stripping it: stripping turned the European "28,50" into 2850, a 100x pay rate, silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- writer tripwire listing every payroll-hours writer, plus a real TOCTOU test (loader answers unlocked then locked) proving PUT re-checks before the write - assertPeriodUnlockedOrThrow throws the same message the routes return - $0-rate: unknown role fails closed, salaried MANAGER exempt end to end - export route auth matrix through a new DI factory: 401 unauthenticated, 403 FIELD_CREW, 403 MANAGER without financialReports, 200 ADMIN and 200 financialReports, 409 not-ready, 400 for bad or over-long ranges — and the refusals never run the payroll query - blocking now covers zero-duration and unsettled-DEFERRED entries; the open-punch-today regression has its own test - CSV injection is pinned by a unit test AND by the golden files (the fixture carries a formula-lead project name) - the export hash distinguishes two periods with identical summaries but different detail Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…re the range validator
B1 (Codex) — a lock froze only [periodStart, periodEnd), but overtime inside a
period depends on hours in the same workweek OUTSIDE it. Editing the Sunday
before a Monday-start period moved the locked period's OT split. The lock
predicate now covers the workweek ENVELOPE: the union of the Mon-Sun OT week
(overtime.ts, WA law) and the configured PAYROLL_WEEK_START boundary. Both are
load-bearing; they only coincide on the default.
B3 (Codex) — the salaried list was env config, and an email absent from a
config string is indistinguishable from "hourly": fail-open by construction.
User.payType ("HOURLY" | "SALARY", nullable) is the answer now; the env list is
a fallback for rows it has not answered. NULL is deliberately not "hourly" —
guessing either way is a wrong paycheque. The apply script seeds it once from
PAYROLL_SALARIED_EMAILS plus the ACTIVATED hourly roles.
M9 — validatePayrollRange is one shared validator (day-key shape, REAL calendar
day, direction, 62-day cap) so the endpoint, the lock action and the page can
never disagree about what range is acceptable.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… overlap-safe locks B2 (Codex) — loadGustoExport ran WA meal settlement as a side effect, so a GET request and an ordinary page render mutated payroll rows. It no longer writes at all. An unsettled DEFERRED day already blocks the export; settling is now an explicit "Settle deferred days" button (settleDeferredDaysForPeriod, ADMIN or financialReports) that still skips today, anyone still clocked in, and locked periods. Also from B2: the page decided lockedness with an EXACT-range lookup, so an ad-hoc range overlapping a locked period reported unlocked and offered to lock it again. Lockedness is an OVERLAP query everywhere now. Readiness (open / needsReview / zero-duration / deferred) is checked over the workweek ENVELOPE, not the period: an open punch in the trailing partial week sits outside the period but still decides its OT split. Plus a new unknown-pay-type blocker for anyone with in-period hours and no payType. M8 — lockPayrollPeriod SELECTs overlapping PayrollPeriod rows FOR UPDATE inside its transaction and refuses a conflicting range; two overlapping periods would each hold a different exportHash for the same punches. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…egacy EMPLOYEE H7 — blocking the OFFICE as well as the worker created a punch nobody could close: past MAX_SHIFT_HOURS every path refuses it and nothing sweeps a stranded punch. A manager closing someone else's $0-rate punch now succeeds and stamps needsReview + a "closed at a $0 pay rate" reason, which the payroll export then refuses to run past. The worker-side block stays (a phone cannot fix a rate) and its message now says the office can close it or set the rate. H6 — EMPLOYEE is a real legacy role value: absent from ROLE_LABELS/ROLES so nothing creates one, but a live branch in access-rules.ts and schedule-core.ts, so prod rows can carry it. It is hourly in the rate guard (already true under block-by-default, now pinned by a test) and allowed in bug-widget-auth. payType is threaded through the guard, both close paths, /api/users and the rates panel, where it is directly editable — the export blocks until it is set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… was shown
H5 — a pay rate is money and never touches a JS float now. parseRateValue
returns CANONICAL DECIMAL TEXT and refuses anything ambiguous: commas ("28,50"
was becoming 2850), exponent notation (Number("1e2") is 100), and more than two
fractional digits (28.005 is a half-cent decision for a human, not a rounding
rule in an importer). The text goes to new Prisma.Decimal(text) at write time.
H4 — apply was an arbitrary "set these pay rates" endpoint that trusted a body
the browser could replay. The preview now returns a fingerprint over each row's
target, its new rate/payType, AND the user's values AT PREVIEW TIME; apply
re-derives it from the live database and refuses if anything moved. Disabled
accounts are refused at both ends.
The preview also reads the compensation-type column when the file has one.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- workweek envelope: a punch in the trailing partial week freezes with the period, and a Sunday-start pay period still drags in its Mon-Sun OT week - readiness over the envelope, with the pre-fix behaviour asserted alongside so the test would fail if the envelope were dropped - unknown payType blocks; a payType-less worker with no in-period hours does not - payType beats role and env BOTH ways; EMPLOYEE is hourly - manager close at $0 succeeds, flagged, with a $0 laborCost - exact-decimal parsing: commas, exponents, sub-cent all refused - preview fingerprint refuses a replay after someone else moves a rate - the shared range validator: shape, fake calendar days, direction, and the 62-day cap on the boundary and one past it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion (gate 1)
The check and the write were separate statements against DIFFERENT rows, so no
row lock could order them: writer checks unlocked -> locker inserts and
recomputes -> writer writes, and the locked period's exportHash described hours
that changed a moment later.
Now ONE key, two modes. Every hours writer takes
pg_advisory_xact_lock_shared('payroll-period') inside the same transaction as
its check and its write (writers do not conflict with each other, so
concurrency is preserved). Lock creation takes the EXCLUSIVE lock first, which
waits for in-flight writers and also serializes two concurrent lock creations —
that is what makes the overlapping-period check sound, since FOR UPDATE cannot
lock a row nobody has inserted yet. xact locks release on commit or rollback,
so there is no leak path.
Writers wrapped in a transaction where they had none: time-expense-actions
update/delete/deleteMany, timeclock create/update/delete, createTimeEntryCore,
POST clock-in and PATCH. PUT's guard moved inside the existing close
transaction; DELETE's runs inside deleteEntryAndSettle via a new guard hook.
Tests use the injected-sequence pattern and the tripwire now also asserts no
bare prisma.timeEntry write survives. A true two-connection test needs CI
Postgres — recorded in the test, not faked.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…igration The migration creates it TIMESTAMPTZ(6) (like every other timestamp Phase 5 adds) but schema.prisma declared it bare, which Prisma maps to TIMESTAMP(3). The migrations-reproduce-production check caught the drift correctly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… locked exports Codex round 3, all six. 1. Meal settlement could rewrite a locked period. It ran in its own transaction after the close released the payroll lock, so a locker could commit in between. settleDayInTx now takes the shared advisory lock and refuses any day inside a locked period, and the clock-out and edit paths settle INSIDE their own write transaction (settleDayWithinTx) instead of afterwards. 2. Writers validated timestamps captured before their transaction, so a concurrent move could put a row into a period that was then locked. withPayrollWriteTx now takes entry IDS, re-reads them SELECT ... FOR UPDATE inside the transaction and validates the STORED startTime; the clock-out close does the same. A caller can no longer pass a stale date by accident. 3. A locked export is now SNAPSHOTTED. The CSVs are built from mutable inputs (name, email, payType, Gusto id mapping, project/cost code after logistics recoding), so recomputing could not reproduce the file payroll received. Lock stores both CSVs, downloads serve them verbatim and skip readiness, unlock deletes them. The page shows live-vs-frozen drift. 4. Row claims are HMAC-signed with NEXTAUTH_SECRET and now include the OLD pay type; the transactional CAS constrains hourlyRate AND payType, so a concurrent null-to-SALARY correction can no longer be reverted by a stale preview, and a forged claim is rejected (timing-safe compare). 5. Dropped the endTime backfill and synthesis entirely. durationHours on a manual entry is PAID time, not a span: synthesising one made WA settlement deduct a meal it never owed and reprice at the current rate. The readers were fixed instead — open means endTime null AND durationHours null. 6. The settle button now queries the same workweek envelope readiness uses, so it can clear every blocker it reports. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
76f6d5d to
5e93c07
Compare
|
Three release blockers:
VERDICT: REQUEST_CHANGES |
…rify schema definitions Review round 20, three items. 1. PATCH re-reads userId/projectId/startTime/endTime under the row lock and re-authorizes against the RE-READ owner. Everything above that lock — the authorization, the period check, the pricing target, the day locks — was decided from a copy taken before the transaction opened, so a concurrent reassignment made every one of those answers about a different person: the edit priced from the OLD owner's rates and settled onto the OLD owner's day. Any owner change is now a typed ENTRY_MOVED refusal. The telemetry write is conditional (WHERE id AND userId = the authorized user), so one worker's phone can no longer stamp geofence data onto somebody else's punch after a reassignment. 2. DELETE's guard re-reads userId AND startTime and treats OWNER OR DAY change as movement. Comparing the day alone missed the case that matters most: a same-date A->B reassignment leaves the day key identical, so the guard passed while the locks held and the settlement about to run still belonged to A. deleteEntryAndSettle now takes the COMPLETE day-lock set keyed on owner AND day (a day lock is `wa-breaks:<user>:<day>`, so the owner is part of it) — the old condition compared only the day and so took no extra lock at all in exactly that case. The route retries once with the corrected set, then ENTRY_MOVED. 3. EXPECTED_OBJECTS now carries DEFINITIONS, and every lookup is schema-qualified: column type/nullability/default via information_schema, index columns and uniqueness via pg_indexes.indexdef, CHECK expressions via pg_get_constraintdef, FK confdeltype, and relrowsecurity + policy count for all three protected tables (PayrollPeriod, User, HelpSubmissionQuota — zero policies IS the deny-all, so a policy appearing is drift). The ad-hoc "5/5 discard bits" and "9/9 columns" counts are gone; they asked only whether things existed, which an index on the wrong columns or a CHECK rewritten to `CHECK (true)` passes. Drift tests break one thing at a time and assert each is reported: RLS disabled, a policy added, wrong index columns, a UNIQUE index demoted, a weakened CHECK, an FK back to CASCADE, a decoy in another schema, a lost NOT NULL — plus a clean-database control and a teardown check, because a verifier that cannot fail is not a verifier. Concurrent reassignment is covered by real two-connection DB tests as well as the source tripwires. 986 unit tests pass, 21 DB tests skip locally (they run in CI), build clean, eslint 0 errors on changed files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…ail-closed salaried list Review round 21, five items. 1. PATCH's compare-and-set was a tautology. It compared against the updatedAt re-read INSIDE the transaction, where the row is held FOR UPDATE and a value cannot differ from itself — so a concurrent same-owner edit that changed the endTime, meal outcome or attestations sailed straight through, and this request wrote data computed from a copy taken before it. The CAS is now on `existing.updatedAt`, the value the request was computed from, asserted early inside the transaction as well as in the write's WHERE. State-dependent fields (endTime, meal, flags, the as-clocked snapshot) are read from the locked row. 2. The zero-rate FLAG was written from the unlocked pre-read. That warned "$0 pay rate" on entries whose rate had since been fixed, and missed entries whose rate had since been zeroed. The decision and appendZeroRateReview now run only on the locked live rate; the pre-check stays as a cheap fail-fast. 3. The provider lease was 120s while a resumed submission made TWO 90s calls — the pair could run 180s and outlive the fence holding it. There is now ONE absolute deadline (180s) shared by the marker search and the create, the lease is 240s, and it is renewed between the two calls, fenced on the token so a superseded attempt cannot extend a lease it no longer holds. 4. payroll-config's salaried list no longer defaults to two named employees. That guess failed OPEN in the direction that silently underpays: if either had actually been hourly, their hours would have been dropped from the summary csv. Unset env now means an empty set — nobody exempt. payType SALARY remains the durable answer and PAYROLL_SALARIED_EMAILS the override. 5. unknownPayType blocks only users with at least one IN-PERIOD entry, and gusto-export-db no longer pulls in every activated null-payType hourly-role user regardless of hours. Hiring somebody on Friday froze Monday's payroll for everyone until an admin answered a question about a person with no hours in the run. A 0.00 row is not a payment; the pay type is still required before their first real hours reach a pay run. 992 unit tests pass, 21 DB tests skip locally (they run in CI), build clean, eslint 0 errors on changed files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The reassignment DB test's seed helper inserted a Client row with an updatedAt column that does not exist, so the migrations CI job failed with 42703. It also omitted initials, which is NOT NULL with no default and would have failed on the next line. I wrote that INSERT from the shape of the neighbouring Project insert (which does have updatedAt) rather than from the schema. Audited every INSERT in the DB tests against the baseline migration afterwards; this was the only one wrong. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two DB tests that import the app's prisma singleton hit its DATABASE_URL validation, which requires pgbouncer=true. That guard is production safety: without the flag the Supabase transaction pooler returns 42P05 'prepared statement already exists' and the site goes down (CLAUDE.md). Weakening it to let a test through would trade a real outage risk for CI convenience. The repo already has the answer — the Playwright job appends ?pgbouncer=true to its own localhost URL. Same thing here, scoped to the two steps that need it. On a direct connection the flag only tells Prisma to skip prepared statements, which is harmless. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Round 20 replaced the presence-only schema check with a definition-level one, changing the dry-run wording from 'would be created or converted' to 'are missing or drifted' and prefixing each line with its table. I updated the script and left this test asserting the old strings, so it failed on the first CI run that reached it. Audited the remaining stdout assertions in the apply-script DB tests against the script's actual console output; the other four line up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…aming salaried staff in .env.example
Codex adversarial review, round 21. Two findings fixed, one defended.
P0 — the settlement day guard and the settlement itself disagreed about what
"2026-08-17" meant. Settlement selects the rows it rewrites with
toCompanyDayKey, which is hardcoded to COMPANY_TIME_ZONE (America/Los_Angeles);
the guard resolved CompanySettings.timeZone instead. The two are the same value
today, so nothing was wrong in production — but the moment the company zone
moved east of the default, the guard read the key as New York midnight, three
hours BEFORE the Los Angeles midnight those rows actually sit at. That instant
falls outside a locked period's envelope (a locked period carries and keeps the
zone it was locked in), so the guard passed and settlement rewrote
durationHours / laborCost / burdenCost inside an already-exported period.
The guard now takes COMPANY_TIME_ZONE, and settleDayInTx's un-guarded branch
calls the same helper rather than re-deriving the zone itself, so there is one
answer to "which zone is this day key in" on both sides.
Two regression tests in tests/payroll-period-lock.test.ts:
- the divergence is asserted directly — the same key rejects in
COMPANY_TIME_ZONE and passes in America/New_York, which is why passing this
guard a configurable zone is unsafe;
- behaviourally, settleDayWithinTx still refuses the locked LA day with
COMPANY_TIMEZONE=America/New_York in the environment. Mutation-checked:
reintroducing a configurable zone in the guard fails this test.
P1 — .env.example still shipped PAYROLL_SALARIED_EMAILS with CJ's and Richard's
addresses, contradicting the empty code default that #441 introduced. An example
file is COPIED, not read, so it put the guessed list back into every fresh
environment and re-created exactly the failure the empty default exists to
prevent: a salaried guess drops that person's hours from the pay run. The value
is now empty, with a comment saying so, and the "no employee is named in the
payroll source" tripwire in tests/payroll-round16.test.ts covers .env.example
too. The same stale claim is corrected in payroll-config.ts's header and in the
Phase 5 spec's implementation note.
P2 — DEFENDED, not fixed: the bug-report throttle's fixed UTC-hour bucket in
src/lib/help-chat/submission-guard.ts. Pre-existing code this PR does not
modify, and out of scope for a payroll / time-entry change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…l-fast and the refusal text Codex round 23. Two fixes, one defence. FIX 1 (High) — clock-out could 423 PERIOD_LOCKED on a punch that is not locked. The clock-out route wired its own payrollPeriod.findMany as the fail-fast loader, and that copy dropped `timeZone` from the select. Every locked period therefore arrived looking like a legacy row, so lockedPeriodFor fell back to TODAY's company zone and re-derived the workweek envelope of a period that was locked — and paid — under a different one. After a CompanySettings.timeZone change the envelope moves, and the check refuses punches the in-transaction guard (which does read the stored zone) considers perfectly writable. The two guards disagree, the 423 is unanswerable, and the worker's open shift is stranded on the phone with no way to close it. The route now wires the canonical loadLockedPeriods() straight in, and both canonical loaders share ONE exported LOCKED_PERIOD_SELECT — a repeated literal is exactly how the column went missing, and a shared object means the next column added reaches every reader at once. FIX 2 (Medium) — a refusal named the wrong dates. periodDisplayRange/periodLockedMessage formatted periodStart and periodEnd with the global COMPANY_TIME_ZONE, and the Pick<> parameter type discarded period.timeZone so the stored zone could not even be passed. Enforcement was already correct; only the message was not, which is the worse half to get wrong: someone told "2026-08-16 to 2026-08-29 is locked" goes looking for a period that does not exist, and an admin unlocks the wrong one. Both now format in the period's stored zone, falling back to the company constant only for rows written before the column existed — which is the zone those were locked under anyway. Tests (tests/payroll-period-lock.test.ts): - a company-zone change does NOT strand an open punch outside the locked period — a period locked in Pacific/Honolulu, a punch in the three-hour band that is free in HST but inside the envelope Los Angeles would derive today. Carries an explicit COVERAGE BOUNDARY note: its loader is injected, so it proves the route honours a stored zone end to end and cannot see what the production loader selects. The dropped column is caught by the two tests below instead. - the clock-out route uses THE canonical loader, not a local copy (tripwire: no hand-rolled payrollPeriod.findMany in the route). - every locked-period read selects the stored timeZone, from ONE shared select — asserted by IDENTITY against LOCKED_PERIOD_SELECT, not by shape, because a second literal that merely looks the same today is what drifted. - the refusal names the dates in the zone the period was LOCKED in — a period locked in Asia/Tokyo, where the same two instants read as a different pair of days in Los Angeles. Checked through periodLockedMessage, the 423 body and the thrown server-action variant, with a control asserting the zoneless row still produces the old (wrong) pair. Both new assertions were mutation-checked: reverted against the unfixed source, the Tokyo message test and the loader tripwire fail; restored, they pass. DEFENDED, not fixed (Medium) — src/lib/help-chat/submission-guard.ts:163, the help-chat rate limit buckets by wall-clock hour. Pre-existing code, untouched by this PR, and unrelated to payroll or the Gusto/mobile surface. Same finding was raised and defended in round 22; out of scope here too. It belongs in its own change against the help-chat limiter, where the bucketing can be reworked with tests that actually exercise the limiter. Verification: npm run typecheck clean; npm run build:next clean; npm run test:unit 998 pass / 0 fail / 21 skipped (1019 total); eslint on both changed source files 0 errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
I found two release-blocking holes. I reviewed the matching PR #441 head (
VERDICT: REQUEST_CHANGES |
…eEntry/HelpRequest Codex adversarial review on PR #441 requested two changes: 1. assertDayUnlockedInTx checked only the settlement day's opening instant (LA midnight) against a locked period's envelope. A period locked in a zone WEST of the company zone (e.g. Pacific/Honolulu) can start its envelope AFTER that instant but still inside the same LA day, so the guard passed and settlement went on to rewrite entries later that same day that fall inside the locked envelope. Added lockedPeriodForDay(), which checks the WHOLE settlement day [dayStart, dayEnd) for overlap with each locked period's envelope, and wired assertDayUnlockedInTx to use it. Added a Honolulu regression test, and updated the pre-existing wrong-zone test whose "misses the lock" scenario the wider window now closes as a side effect. 2. The migration enabled RLS/REVOKE for PayrollPeriod, User and HelpSubmissionQuota but not TimeEntry or HelpRequest, leaving raw payroll hours and crew help reports reachable by a leaked anon/authenticated Supabase key through PostgREST. Added matching ENABLE ROW LEVEL SECURITY + REVOKE statements to the migration and to scripts/apply-payroll-phase5.mjs (kept statement-for-statement in parity per the apply-script/migration drift lesson), and recorded both tables in prisma/prisma-blind-spots.json and the parity test's RLS assertion. npm run build passes; tests/payroll-period-lock.test.ts (53), tests/payroll-apply-script-parity.test.ts (11), and the full test:payroll suite (244) all pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VERDICT: REQUEST_CHANGES |
…pe replay, and salaried export blocker - payroll-parent-delete.ts: refuse deletion of a user/project whenever ANY time entries exist, locked or not. Historical paid entries predate PayrollPeriod, so the old lock-only check read them as safe and destroyed them silently. Replaces the delete-unlocked-entries path with a single count-and-refuse (TimeEntriesExistError), backstopped by the RESTRICT FK. - actions.ts (setUserPayType): bump lastRateSyncAt when changing pay type, so an HOURLY -> SALARY -> HOURLY cycle can no longer restore the exact signed state and replay an old rate-import approval token. - gusto-export-core.ts (unknownPayTypeBlockers): consult the same isSalaried predicate the summary CSV classifier uses before blocking on a null payType, so a PAYROLL_SALARIED_EMAILS override can actually unblock an export instead of being classified as salaried everywhere except the blocker. - Updated tests/payroll-parent-delete.test.ts and tests/payroll-writer-manifest.test.ts for the new refusal-only behavior, and added a regression test in tests/gusto-export.test.ts for the env override consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Scope note: the local runner failed before executing shell commands (
VERDICT: REQUEST_CHANGES |
…in settlement
Codex adversarial review, round 22. Two findings fixed, three defended.
P0 — settleDeferredDaysForPeriod derived its per-row dayKey with
resolveCompanyTimeZone() (the configurable company zone), but settleDay's
row selection and guard both key off toCompanyDayKey, hardcoded to
COMPANY_TIME_ZONE (America/Los_Angeles) — the same invariant round 21 (#144e28d4)
already established for every OTHER settleDay caller (PUT/PATCH time-entries).
For a non-Pacific configured zone, a midnight-boundary entry could be labeled
under one calendar date here and then matched against a different date's rows
inside settleDay. Fixed by deriving the deferred-settlement dayKey with
toCompanyDayKey, same as every other caller; the isDayLocked pre-check now
reads the key back in COMPANY_TIME_ZONE too, for the same reason. The pay-period
query window itself stays in the configured zone, since that's a legitimate
business setting independent of the WA-break settlement-day definition.
NOT changing settleDay's internal zone (the literal ask) deliberately: that
would revert the round-21 fix and fails
"settlement stays locked out of an LA-day after the company zone is switched
east" in tests/payroll-period-lock.test.ts by design.
P1 — settleDayInTx read the owner's User row FOR SHARE before locking any
TimeEntry row, while every other payroll writer (closeTimeEntry via
assertEntriesUnlockedInTx) locks TimeEntry rows first and only reads the
owner FOR UPDATE afterward. The opposite order let a settlement and a
concurrent clock-out/edit on an overlapping window each hold what the other
needed next and deadlock. Settlement now takes a FOR UPDATE on the day's
candidate TimeEntry rows (sorted by id, matching THE GLOBAL LOCK ORDER in
payroll-period.ts) before reading the owner, instead of dropping the shared
lock outright — preserving the one-consistent-rate-per-transaction guarantee
readOwnerRatesForShare exists for.
P2/P3/P4 — DEFENDED, not fixed:
- clock-out CAS gaps in time-entries/route.ts are pre-existing and untouched
by this PR.
- the signed rate-import preview replay concern has no identified writer
that bypasses setUserPayType's lastRateSyncAt bump; theoretical.
- the help-chat throttle's fixed-hour bucket is pre-existing, non-security,
and out of scope for a payroll PR.
npm run build passes; tests/payroll-period-lock.test.ts (150), test:payroll
(245), and test:unit (999 pass / 21 pre-existing skip) all green, including
the round-21 zone-divergence regression tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Reviewed the matching PR #441 head ( Required changes:
VERDICT: REQUEST_CHANGES |
Phase 5 of the receipt-pipeline rebuild: Gusto rates in, hours out, crew bug button
Plan:
docs/plans/RECEIPT-PIPELINE-V2-PLAN.md(decision 6), spec:docs/plans/PHASE-5-GUSTO-AND-MOBILE-RELEASE-SPEC.md. Pairs with gtr-probuild-mobile PR #3 (app 1.1.2).Why
Pay rates live in Gusto; ProBuild costs labor from
User.hourlyRateand a$0rate silently costs labor at zero. There was no way to hand approved hours to Gusto, and the old/api/gusto/exportroute had no role check at all. Crew could not file bugs because the help-chat routes were ADMIN-only.What
User.lastRateSyncAt, Gusto employee-CSV import with a preview diff (matched member's email shown; name-only matches badged; commas refused).ZERO_RATE_BLOCKEDand leaves the punch open; block-by-default with ADMIN/FINANCE and salaried emails exempt; manager PATCH mirror; red badge on the manager time page.GET /api/time-entries/export/gusto(ADMIN orfinancialReports): regular/OT per employee per period reusingovertime.ts(per workweek, never re-derived) and never re-deducting meals; 409 while any in-range entry is open,needsReview, zero-duration, or an unsettled DEFERRED day; summary + detail CSVs with formula-lead neutralization; deterministicexportHashover both CSVs. Old ungated route deleted.PayrollPeriodtable; lock recomputes blockers + hash inside one transaction; 423PERIOD_LOCKEDenforced in every TimeEntry writer (API routes, server actions, timeclock actions, core create).authenticateMobileOrSession, exact proxy allowlist entries.src/lib/payroll-config.ts:PAYROLL_PERIOD,PAYROLL_WEEK_START,PAYROLL_SALARIED_EMAILS(defaults pending Justin).Review
Independent checker PASS. Codex unavailable at review time (OpenAI capacity); an Opus deep review found 3 blockers (lock bypass via four other writers, salaried MANAGER stranded by the rate guard, unsettled DEFERRED day exporting at full pay) and 5 majors (check-then-act lock, CSV injection, dropped zero-hour entries, hash determinism, rate-import evidence); all fixed. Codex review still to run via the CI gate.
Tests
npm run test:unit623/623,npm run buildclean.Deploy order
node scripts/apply-payroll-phase5.mjsagainst prod (additive, idempotent).PAYROLL_*defaults with Justin before the first real export.🤖 Generated with Claude Code
Prod state — the DDL is ALREADY APPLIED (2026-09-02, accidentally)
The deploy step for this PR is verification-only. Do not run a real apply.
On 2026-09-02 I ran
node -e "import('./scripts/apply-payroll-phase5.mjs')"to inspect the module's exports. The script calledconfig({ path: ".env.production.local" })at module scope, so the import loaded production credentials and executed the entire migration as a side effect. Confirmed afterwards by a read-only query, and independently re-verified by the coordinator:TimeEntry_userId_fkeyandTimeEntry_projectId_fkey→confdeltype = 'r'(RESTRICT, live)User.payType,User.lastRateSyncAt,PayrollPeriod+discardedAt/discardedById/discardedReason+ thePayrollPeriod_discard_unlockedCHECK: all presentpayTypeset for 0 users — the seed wrote nothing.PAYROLL_SALARIED_EMAILSis unset in prod, and the hardcoded CJ/Richard default had already been removed, so no pay type was guessed for anyone.Live consequence until this merges. The FK change is on production while the deployed build still deletes users and projects without clearing their time entries first. So right now, on prod, deleting a user or project that has any time entry fails with a foreign-key error instead of succeeding. That is the intended end state — the old CASCADE silently destroyed paid payroll history — but it is live ahead of the code that handles it. Decision (Justin notified): leave it; failing loudly beats cascading paid hours. A revert to CASCADE is available on request.
Deploy step for this PR:
Expected output against prod's current state:
[dry-run] nothing to do — all N objects this script manages are already present.If it reports anything missing, stop and read before running a real apply.--dry-runis now genuinely read-only for the whole script. It previously gated only thepayTypeseed and executed every DDL statement regardless, which would have made it a lie in exactly the situation it is now being used for. It returns before the statement loop, and a CI test drops an object, dry-runs, and asserts it is still missing.Root cause fixed on this branch. Every side effect in the script now lives in
main()behind anisMainModuleguard, so importing the module does nothing. Tests assert module scope contains noconfig({ path:, nonew PrismaClient(, and no$executeRawUnsafe. Guarding the other apply scripts the same way is spun off as a separate task and deliberately not touched here.Deploy note — pay types are deliberately NOT guessed
scripts/apply-payroll-phase5.mjsseedsUser.payType = SALARYonly for the emails inPAYROLL_SALARIED_EMAILS(default: CJ + Richard). Everyone else stays NULL on purpose. The payroll export returns 409unknownPayTypefor anyone with hours in the period until a human sets their pay type on Company -> Team Members.This is fail-closed by design: a stored value beats the env fallback, so blanket-stamping people
HOURLYwould have permanently mislabelled any salaried person missing from that list (Gusto pays them a salary and the exported hours), and fixing the env var afterwards would have changed nothing.Expect the first export after deploy to refuse until the roster is filled in.
Tests
CI run for
986441de: https://github.com/Clarion1631/probuild/actions/runs/33646250470 — all three jobs green.Playwright:
508 passed (5.1m), 0 failed. The job runs barenpx playwright test, so the wholee2e/directory is in scope,money-pipeline.spec.ts(42 tests) included. Worth being precise about the evidence: the CI reporter isgithub, which annotates failures only and emits no per-spec line for passes — so the pass line above is the aggregate, and the money-pipeline guarantee rests on "it is in the run and the run had zero failures", not on a spec-scoped line I can quote.Two-connection concurrency proof (new). Most concurrency tests on this branch drive one branch through an injected fake, which shows the branch exists but not that PostgreSQL serializes the way the code assumes.
tests/payroll-rate-lock-db.test.tsopens two real connections against the migrations job's throwaway Postgres and makes them contend. From this run:It is opt-in by
PAYROLL_LOCK_TEST_URLand skips in a normal unit run, so it can never touch a developer database.Unit: 973 pass, 0 fail, 2 skipped (the DB tests above, which run in CI).