Skip to main content

Adversarial review: weekly billing run, coverage gaps, end-to-end process, AR — 2026-08-14

Findings-only pass (no code changes). Reviewed against the shipped implementation on main (as of c79f5fa), plus read-only prod queries (project dksjqoknmwpxfqkiygkb) run 2026-08-14.

Scope corrections vs. the review brief. The brief references artifacts that do not exist in this repo or its git history: INVOICING-HANDOFF.md, the four mockups (invoicing-tab-weekly.html, generate-from-time-flow.html, review-and-send.html, hours-override.html), and billing_basis / override_reason columns. The real system has no free-set "billing basis override" at all — billed hours are always derived: min(logged, contracted) with the cap lifted only by an approved approval_requests row (lib/invoice/index.ts:82). The review therefore targets the real override surfaces (overage approval/waiver, hours_reason, Log-hours upsert, Correct/recompute, reissue) and, for Part E, the shipped UI (src/components/admin/invoicing/) instead of mockups.

Severity legend (Parts A/B/D): SILENT = wrong invoice or uncollected money with no signal · CAUGHT = fails loudly / a human would catch it · COSMETIC = non-financial.


Part A — Billing-basis edge cases

KEEP

  • A-K1 — Over-contract billing is enforced by arithmetic, not workflow. EXISTS There is no "approval checkbox the send forgot to check": without an approved approval_requests row for that week, over-contract hours are simply not billed (lib/invoice/index.ts:82-86, generate.ts:269-308). An over-contract bill can only exist through an approval. Direct-RPC bypass is closed: send_invoices / create_facility_invoice are revoked from authenticated (0097:141-142), and 0102/0104 closed the PostgREST write path to time_entries and invoice money columns. Don't "fix" this by adding a workflow gate — the arithmetic is the gate.
  • A-K2 — The two-actors / two-shelves races are closed. EXISTS Send: per-invoice row lock + status recheck in the RPC (0097:50-69). Generate: 0087 overlap-aware dedup + unique constraint, surfaced as "already exists" (generate.ts:229-248, 518-526). Send-time staleness guard refuses any draft whose stored total ≠ live recompute (send.ts:420-497). Overage approval is a single-use CAS claim bound to a ±0.01h snapshot of the hours the facility actually saw (overage.ts:238-271).
  • A-K3 — PHP/FX cannot corrupt an invoice. EXISTS Billing is USD-native end to end (assignment hourly_rate_cents, integer cents everywhere). FX exists only on the payroll side (payments.exchange_rate_used, 0002:364), locked per payout at Wise-quote time. A rate-assumption change is a payroll-margin question, never an invoice-correctness one. The brief's premise doesn't apply — worth recording so nobody "fixes" it.
  • A-K4 — Under-contract billing requires an audited reason. EXISTS requireReasons + per-line hours_reason (0075), or a recorded blanket confirmation from the cockpit batch path (generate.ts:459-469, invoicing.ts:152-166).

IMPROVE

  • A-I1 — Approval binds at approval time, not at billing time. EXTEND · SILENT (over-billing → dispute) approveOverage correctly expires if hours changed before the facility clicked — but nothing re-checks after. Once a week has any approved row, the cap is lifted for the current actual hours (generate.ts:306-307) and the variance flag is suppressed (lib/invoice/index.ts:244). Facility approves 45h; 3 more hours get logged late; the invoice bills 48h uncapped, unflagged. The staleness guard forces a re-Correct, but Correct recomputes with the same lifted cap. Minimal fix: cap the lifted line at approval_requests.hours_worked (bill min(actual, approvedSnapshot) when approved), or re-raise the variance flag when actual > snapshot + ε.
  • A-I2 — acknowledgeUnapproved is batch-wide, but reads per-invoice. EXTEND · SILENT (narrow) The dialog computes one flag: chosen.some(d => d.unapprovedHours > 0) (ReviewAndSendDialog.tsx:105-112), and the RPC applies it to every id (0097:95-99). Re-checking one flagged draft acknowledges unapproved hours on every selected draft — including hours that became unapproved after the dialog snapshotted. Minimal fix: pass the acknowledged invoice ids to the RPC instead of a boolean.
  • A-I3 — Hours entry is silent last-write-wins between actors. EXISTS (accepted) · CAUGHT-ish createManualTimeEntries upserts by (contractor, assignment, date); two people entering different corrections for the same day silently overwrite each other (LogWeekHoursForm.tsx:23-30). Acceptable for a one-operator shop; revisit only if a second biller ever exists.
  • A-I4 — Variance-flag operator drift between grid and shelf. EXISTS · COSMETIC The run RPC flags at >= threshold and ignores per-line approved overage (0088:119-121); the lib flags strictly > with approval suppression (lib/invoice/index.ts:244). At exactly-threshold, or on an approved-overage week, the grid shows ⚠ where the shelf shows none. Advisory-only either way; align the SQL to the lib when next touching the RPC.

REDO

  • A-R1 — A mid-week assignment change silently drops or re-prices hours. EXTEND · SILENT (under- or mis-billing) pickLatestPerContractor chooses exactly ONE assignment per contractor per week (generate.ts:125-141); entries are then summed only for the chosen assignment ids (generate.ts:263-267; mirrored in weeklyRun.ts:110-126 and the RPC's distinct on at 0088:35-48). So the standard way to change a rate mid-week — end the old assignment row Tuesday, new row effective Wednesday — makes Mon–Tue's hours vanish from the invoice: no line, no gap flag (hours were logged), no variance (the chosen assignment's own numbers look consistent). Payroll may still pay those hours. The mirror failure: editing the rate on the same row applies the new rate retroactively to the whole week — no proration, whole-period restatement. Why a patch won't hold: one-assignment-per-contractor-week is a structural assumption repeated in three mirrored places (RPC, preview, shelf) — patching one desynchronizes the live totals from the stored draft and trips the staleness guard forever. Correct approach: sum and price per assignment (one invoice line per in-window assignment, each capped by its own pro-rata contracted hours), keeping pick-latest only as a display collapse. Known sibling issue: #102 (two-facility contractors resolve to one arbitrary assignment).

Part B — Coverage-gap edge cases

KEEP

  • B-K1 — Detection is live, never cached. EXISTS weekly_billing_run recomputes on every page load (weeklyRun.ts:43-53); a late-entered timesheet clears the gap on next render, and the send-time staleness guard covers the invoice side. A stale cached gap being billed/excluded is structurally impossible. Keep it derived.
  • B-K2 — Deliberate non-coverage has a first-class home. EXISTS The dated billing pause suppresses billing and gap noise, auto-resumes, and shows as its own status (0088:44-47, 126-130).
  • B-K3 — Period boundaries can't double-count. EXISTS Entries are whole-day (entry_date, unique per assignment/date, ≤24h — 0002:221-236); Mon–Sun windows are disjoint, so a week's hours belong to exactly one run. Legacy Sun–Sat invoices overlapping a Mon–Sun week are handled by 0087's overlap-aware dedup everywhere (run, shelf, preview, create-RPC). A midnight-spanning shift lands on whichever date the contractor logged — there is no time-of-day to split; that's the accepted model, not a bug.

IMPROVE

  • B-I1 — Data-entry error vs. real gap: not distinguished, and the wrong-facility case bills the wrong facility. NET-NEW · SILENT Every gap is the same boolean (logged = 0 ∧ no approved leave, 0088:74-82). The nastier half: hours logged against the wrong assignment of a two-facility contractor bill the wrong facility (each side looks locally plausible — one facility gaps, the other bills within its cap). Nothing flags "same contractor, hours at two facilities on the same day" or "gap here + unusual hours there". Minimal fix: a same-day multi-facility anomaly feeding review (the unused time_entries.anomaly_flagged column, 0002:233, is a ready-made hook).
  • B-I2 — A gap (or 'none') week that scrolls past never resurfaces. NET-NEW · SILENT (uncollected) — shared with Part C The cockpit is strictly per-week and anchors to the latest closed week (page.tsx:123-135, PeriodBar.tsx:20-31). A gap left unresolved in week N is invisible in week N+1; no query anywhere lists "weeks with logged payable hours and no non-void invoice". This is the second-biggest quiet-money hole in the system (after AR). Minimal fix: an "Unbilled weeks" strip on the cockpit — weeks in the last N where any facility had payable hours and no invoice — one SQL view, one alert row.
  • B-I3 — Alert fatigue on structurally idle assignments. EXTEND · CAUGHT (but trains rubber-stamping) An assignment a facility keeps open but doesn't use every week re-flags a gap every single run, forever; the only outs are pausing the whole facility or ending the assignment. With a red banner weekly (InvoicingCockpit.tsx:221-230), the operator learns to skim past gaps — which is exactly how a real one slips through. Minimal fix: per-week "dismiss gap with reason" (audited), or a per-assignment "not expected weekly" flag that downgrades its gap to informational.

REDO

  • B-R1 — Any leave overlap silences the whole week's gap. EXTEND · SILENT (uncollected) The gap predicate checks that an approved leave overlaps the week at all, not that it covers the gap window: lr.start_date <= p_period_end AND lr.end_date >= p_period_start (0088:76-82). A one-day approved leave suppresses a five-day gap; the row then lands in 'none' / "No activity" (logged=0 → the logged_hours = 0 branch at 0088:134) with a neutral badge and $0 billed — nobody is asked to review anything. Four worked-but-unlogged days are never billed and never chased. Same window logic in the facility shelf's leave status (weeklyRun.ts:625-641). Why a patch won't hold: the boolean has no notion of which days leave covers, and both sides are day-granular already. Correct approach: day-level comparison in the RPC — gap unless (leave days ∪ logged days) covers the expected working days; anything partial stays gap.

  • B-N1 (note) — The gap block is UI-only: generateDraftInvoice never checks coverage_gap (invoicing.ts:135-176); the UI hides generate for gap rows (InvoicingCockpit.tsx:33-39). A direct server-action call bills the partial week (gapped contractor bills $0). Low risk (owner-gated), but the server should own the rule if it's a rule.


Part C — End-to-end process

Prod snapshot (2026-08-14): 0 open drafts · 1 check awaiting review · 4 stale unapproved entries (1 contractor, oldest 2026-07-20) · cron.job is EMPTY — none of the five documented scheduled jobs has ever been wired (docs/cron-setup.md) · $75.0k outstanding, $53.7k past due.

#StageCurrent stateSPOF?Recommended forcing function
1Time capture (Hubstaff pull, manual entry, shelf Log-hours)Manual-only — imports and entries happen when a human triggers them; no "hours missing for week N" signal beyond the gap flagY — contractor + ownerThe unbilled-weeks strip (B-I2) doubles as the missing-hours alarm
2ApprovalManual-only — owner-only bulk approve, no cadence; unapproved hours still bill (PAYABLE includes submitted) behind the soft gate, so nothing ages out of billing — but nothing forces the approval eitherY — ownerWeekly nudge: "Nh unapproved older than 14 days" on the cockpit; currently only visible per-week
3Draft generationManual-only, no process for the skipped week — if nobody opens the tab on Monday, billing simply doesn't happen and the week never resurfaces (B-I2). Sound when exercisedY — owner; illness/vacation = billing halts silentlyEither schedule draft generation (cron → generateDraftInvoice per ready facility) or, minimally, the unbilled-weeks alert. Also: cross-train one staff member; today every mutating step is one person
4Review → sendSound — the strongest stage: recipient + due-date + amount-anomaly shown per row, staleness hard-guard, held/skipped/stale buckets reported, not swallowed (send.ts, ReviewAndSendDialog.tsx)Y — owner clicks itNone needed beyond A-I2 (per-invoice acknowledge)
5DeliveryExists, watched by nobody — outcome recorded (email_status, outbox), failures badge on the list (page.tsx:79-104), resend exists; but a failed delivery waits for a human to browse the list (0 failed today)N (recorded)Surface email_status='failed' count on the cockpit KPI row, not just the All-invoices tab
6Payment capture (checks)Sound tooling, manual trigger — OCR + ref-first targeting + balance-moved guard + unapply are genuinely strong; but an unreviewed check has no ageing alarmY — whoever opens the mail"Check awaiting review for >7 days" nudge
7AR chase (due date → cash)NO PROCESS — pure hope. See Part DY — owner's memory, with financial consequenceImplement the AR page (docs/design/accounts-receivable-page.md)
8Interest / late feesNO PROCESS in effect — engine exists, nightly job exists in code, cron never scheduled, enabled on 1/581 facilities, and accrued interest is display-only (never added to any invoice total/PDF/email)YWire cron (5-minute SQL); decide whether interest is policy or delete the columns
9Record-keepingSound — audit_log on send/void/correct, outbox, invoiced-hours audit (0098), payout ledger (0107)N
10Scheduler itselfNO PROCESS — nothing detects that cron.job is empty; every "nightly" behavior has silently never run since go-liveY — deploy-time human stepAn in-app "last nightly run" heartbeat card (the jobs already hit app routes; record a timestamp and warn when stale)

Single point of failure, named: every mutating step (approve, generate, send, reconcile, chase) is the owner. There is no handoff document — the brief's INVOICING-HANDOFF.md doesn't exist, which is itself a finding. Minimum viable handoff = one page: the Monday loop (stage 3–4 clicks), the check-import loop, and the "what the statuses mean" table from status.ts.


Part D — AR reconciliation & overdue reminders

Classification per piece:

PieceVerdictEvidence
Detection (overdue flip)EXTENDCorrectly derived on read, never stored: lib/invoice/status.ts:24-39, invoice_ar_summary (0089:23-38), KPI card, dashboard ?filter=overdue link. Design is sound (no cron dependency, no stale stored state — prod confirms status='overdue' has never been written). But detection is pull-only: it exists exactly as long as someone opens the page. No push, no digest, no task.
Reconciliation (payment → invoice)EXISTSThe strongest subsystem reviewed: fingerprint-deduped packet import, ref-first remittance targeting with ambiguity holds (lib/reconcile/target.ts), reviewed-allocation binding + balance-moved guard (0099) at confirm (checks.ts:488-621), whole-check unapply (0105). Partial → partial; unmatched payer → needs_review + facility pick. One soft edge: a multi-facility payer's held-back leftover isn't persisted as first-class "unapplied cash" — it's implied by amount_cents − Σapplications, visible only if someone inspects the check. The AR design's UNAPPLIED tile is the fix.
Reminder cadenceNET-NEWfacilities.reminder_schedule, email_template_reminder, email_template_overdue have zero consumers (grep: only db/types.ts + migration 0002) and reminder_schedule is set on 0 of 581 facilities. Pure aspiration — schema without a process.
Interest & escalationEXTEND (interest) / NET-NEW (escalation)Interest engine + nightly recompute + manual per-invoice recompute all exist (lib/interest, cron.ts:34-111, invoices.ts:152-214) — but (a) the cron was never scheduled (prod cron.job empty), (b) it's enabled on 1/581 facilities, and (c) accrued interest is display-only: it never joins the invoice total, PDF, or email, so even a computed figure is never billed or collected. Escalation beyond email: nothing — no ladder, no hold-service, no write-off (debt cannot exit the queue except by payment). All of this is designed, in detail, in docs/design/accounts-receivable-page.md — unimplemented, on this branch.
VisibilityEXTENDWhat exists: the "Outstanding AR" KPI card (lump outstanding + overdue, KpiRow.tsx:19-29) and the ?filter=overdue invoice list. What doesn't, anywhere in the app: aging buckets (31–60/61–90/90+), per-facility/payer AR rollup, ranked chase queue, last-contact memory. Not elsewhere either — this is a real gap, and the AR page design is its specified fix.

The actual test (real data, 2026-08-14)

Westchester Center for Rehabilitation & Nursing: 22 open invoices, $52,801.40 outstanding, $35,721.40 past due, oldest due 2026-04-15 — 121 days. Trace from due_date passing to cash:

  1. Due date passes → nothing happens. Stored status stays sent; "overdue" exists only as a derived label when a page renders it.
  2. Anyone notices only if the owner opens /admin/invoices and reads the KPI card or the red pill — human memory, nudge #0 never fires.
  3. No reminder email is ever sent (no consumer of reminder_schedule/templates) — human memory.
  4. No interest accrues (cron unscheduled; facility not enabled; and it wouldn't bill anyway) — policy exists on paper only.
  5. There is no record of "I called them July 28, they promised payment" anywhere in the system — follow-up cadence lives in the owner's head — human memory.
  6. No escalation rung, no hold-service, no write-off — the invoice sits in sent forever or until a check happens to arrive. Four sibling facilities are in the same state ($6.8k / $6.1k / $4.2k / $0.9k past due).

Every single step depends on one person remembering, with zero system nudges. This is not a hypothetical silent failure — it is currently occurring, measured at ~$53.7k.


Part E — UX/UI pass (shipped screens: cockpit, two shelves, Review & send dialog)

KEEP

  • E-K1 — One component system, status-routed shelves. The brief worried about four stitched-together screens; the shipped UI is one kit (shared SlideOver, Badge tones, Table, status metadata in status.ts) and the row click routes by status to the shelf that usually matters (InvoicingCockpit.tsx:212-215). Consistency is not the problem here.
  • E-K2 — The common weekly path is already short and safely shaped. Clean week: select-all → "Create invoice for selected" → "Review & send" → Send ≈ 5 clicks for the whole book, with the ceremony concentrated at the one irreversible step, drafts deliberately cheap/reversible (InvoicingCockpit.tsx:267-284). The PeriodBar refusing to advance past the last closed week is quiet correctness — keep it (PeriodBar.tsx:17-31).
  • E-K3 — The send confirm confirms something real. Per-draft recipients, due date, and an amount-anomaly check against the facility's recent baseline (ReviewAndSendDialog.tsx:74-89, weeklyRun.ts:770-839) — designed for the tired reviewer, not the attentive one. This is the right pattern; extend it rather than adding more generic "are you sure" steps.

IMPROVE

  • E-I1 — Gap rows open the one shelf that can't fix a gap. (actively causes mistakes) openShelf routes gap to the Facility shelf (InvoicingCockpit.tsx:212-215), which shows "No activity" badges but has no remedy; the Log-hours form lives on the Invoice shelf (InvoiceShelf.tsx:175-199), and a gap row's invoice cell renders "—" (no link, InvoicingCockpit.tsx:338-357). The status whose whole point is "someone should enter/verify hours" has no path to the hours form. Fix: route gap to the invoice shelf, or mount LogWeekHoursForm on the facility shelf's gap state.
  • E-I2 — "Click Correct" points at a control on a different tab. (actively slows the recovery loop) The stale-send result and the shelf's out-of-date note both instruct "click Correct" (ReviewAndSendDialog.tsx:242-259, InvoiceShelf.tsx:263-267) — but Correct exists only in the All-invoices tab's row actions (InvoiceActions.tsx:213-233). The most common exception loop (hours moved → correct → resend) forces a tab switch and a row hunt. Fix: a Correct button in the invoice-shelf footer for drafts (calls recomputeInvoice).
  • E-I3 — Under-contract confirm: two paths, two behaviors. (inconsistent, one is a dead end) Batch path: confirmDanger → confirm → create (InvoicingCockpit.tsx:186-201). Shelf path: generate() ignores needsConfirm and dead-ends on the raw error (InvoiceShelf.tsx:67-78 vs invoicing.ts:168-171). Same judgment, different outcome depending on which button you used.
  • E-I4 — 'None / No activity' is an overloaded bucket hiding the B-R1 leak. (actively hides mistakes) 'none' mixes "no assignments — genuinely nothing to bill" with "assigned people, zero logged hours, gap suppressed by a partial leave" — the exact case where money silently goes unbilled — under one neutral badge (0088:134, status.ts:11). Five statuses is the right count; this one has the wrong contents. Fix: split the label ("Nothing to bill" vs "No hours logged"), and fix B-R1 so the partial-leave case stays gap.
  • E-I5 — Per-row "send anyway" checkbox, batch-wide contract. (cosmetic wording, real semantics — see A-I2) The row copy reads "check to send anyway" as if the acknowledgment were per-invoice (ReviewAndSendDialog.tsx:344-349); the wire contract is one boolean for the batch. Fix with A-I2.

REDO

  • E-R1 — AR has no home; the cockpit's per-week lens can't be stretched to cover it. The one AR surface is a lump-sum KPI card + a flat overdue filter list. Aging buckets, payer grouping, chase ranking, contact memory, escalation — none has a place in the weekly-run mental model, and bolting them onto the cockpit would break its (good) one-week frame. This is a new page, and it is already fully designed: docs/design/accounts-receivable-page.md. Build that rather than extending the cockpit.

Ranked: what to fix first

  1. AR chase is NO PROCESS and is already losing money — implement the AR page (Part D, E-R1). Not hypothetical: $53.7k currently past due with zero system nudges; the worst facility is 121 days out. Everything from detection-push to follow-up memory to write-off is missing, and the design (+ its one collection_activities migration) is already written on this branch. Even a v1 with just the Chase queue + activity log removes the "owner's memory" dependency from every step of the Part D trace. (Compliance: invoicing migration → security-reviewer + human approval; apply via MCP.)
  2. Wire the cron jobs — cron.job is empty in prod (Part C stage 10). Five minutes of SQL from docs/cron-setup.md turns on everything "nightly" that has silently never run (interest accrual, Wise reconcile, pipeline refresh). Add the heartbeat card so this class of failure can't be silent again. Highest value-per-minute item in this report.
  3. Leave-window gap suppression (B-R1) + the 'none' bucket relabel (E-I4). A one-day approved leave hides a five-day gap in a neutral tab — quiet non-billing that matches Nightingale's actual operating pattern (PH contractors, approved leaves, 9+ facilities weekly). One day-level predicate change in weekly_billing_run + a mirrored shelf check closes it.

Close behind: A-R1 (mid-week assignment swap drops hours — strike whenever a rate change or renewal is effective-dated mid-week) and B-I2 (the unbilled-weeks ledger — cheap insurance that also covers the "owner on vacation" SPOF in Part C stage 3).