Invoicing Fix Plan — from the 2026-07-06 Fable review
Source: docs/reviews/invoicing-fable-review.md. Status: APPROVED 2026-07-06 — all decisions (D1–D7) resolved; ready to execute Step 0 → PR-A.
Execution model: one PR per work package, sequential, conventional commits, branch type/short-description, CI green before merge. Any diff touching src/db/migrations/, src/lib/invoice/, or src/server/services/invoice/ gets a Fable-level review before commit (money path rule).
Ordering rationale
Ranked by (money at risk × likelihood it's happening now) ÷ effort. Step 0 is ops, not code, and can happen today. PR-A is trivial and clears the "actively misleading" items immediately. PR-C and PR-B close the two active leaks. PR-D builds the AR chase. PR-E is the highest-severity code defect but lowest frequency, so it goes after the standing leaks. PR-F/PR-G are quality hardening. The runbook closes the single-person risk.
Step 0 — Prod verification (no code, Oliver + assistant, TODAY)
select jobname, schedule, active from cron.job;against prod — confirm the six pg_cron jobs fromdocs/cron-setup.mdare actually scheduled (especiallyaccrue-overdue-interest). If missing, run the SQL template from that doc.- Confirm
RESEND_API_KEY+EMAIL_FROMare set in the prod deployment (else every "send" records emailskipped). - Commit the review report + this plan to
main(docs-only commit).
Deliverable: a yes/no per item recorded at the bottom of this file.
PR-A — fix/invoicing-copy-and-labels (model: sonnet; haiku-simple but one item has logic)
Small, zero-risk-to-money, ships first.
- Billing-policy header copy —
src/app/admin/invoices/page.tsx:522-528: replace "Invoices bill contracted hours…" with the true lesser-of-logged-and-contracted description (mirror the InvoiceShelf wording). - DOW label off-by-one —
src/components/admin/invoicing/LogWeekHoursForm.tsx:19,101-105: delete the hardcodedDOWarray; derive the weekday label from each date (fromISO(d)→ weekday). Also fix the stale "(Sun–Sat)" doc comments here and inPeriodBar.tsx:17-20. Sent · <substatus>badge —InvoicingCockpit.tsxstatus cell: whenr.status === 'sent', append the underlyingexistingInvoiceStatus(paid / partial / overdue — derive overdue viaeffectiveInvoiceStatuswith the row's due date if available, else show stored status). Data is already on the row; display-only change.- Tests: one Vitest case for the weekday-label helper (Monday-anchored week renders Mon..Sun).
Review: sonnet self-review + CI. No Fable pass needed (no money path).
PR-B — feat/overage-aging-and-rerequest (model: opus; Fable review on the SQL)
Closes ranked leak #1 (unapproved overage silently never billed).
- Age + expiry columns on
/admin/overage(src/app/admin/overage/page.tsx): per pending group show days-pending and the approval link'sexpires_atstate (active / expiring ≤3d / expired). Sort oldest first. - One-click re-request for expired/expiring links: re-use
requestOverageApproval(src/server/actions/overage.ts:122) to mint a fresh token + email. Owner-triggered (see Decision D1 — not auto-send by default). - "Unbilled overage" KPI: extend
needs_attention_counts(or add a sibling aggregate in a new migration) to return Σ(overage hours × assignment rate) for pending groups in the trailing 4 weeks; render as a dashboard Needs-attention line + link to/admin/overage. - Tests: aggregate math (SQL or via a query-layer test) + expiry-bucket logic.
Fable review: yes (new SQL aggregate touching billing figures).
PR-C — fix/post-send-drift-and-lock (model: Fable — money-critical RPC changes)
Closes ranked leak #2 (sent invoices silently diverge from recorded hours).
- Lock acknowledged-unapproved entries at send — new migration (
009x_send_lock_all_payable.sql) revisingsend_invoices: on an acknowledged send, flipsubmitted/pending_approvalentries in the window tolockedtoo (currently onlyapproved,0045:319-327). Subtlety:void_invoiceunlocks toapproved— that would launder never-approved hours intoapproved. Record prior status (new columntime_entries.status_before_lockor lock-audit row) and restore it on void. - Sent-week drift surfacing — cheapest correct version: extend
weekly_billing_runto also returnexisting_invoice_total_cents; the cockpit then badges asentrow whose livebillable_amount_cents≠ billed total ("Hours changed since billing — review/reissue"). Trailing-weeks coverage: add a drifted-sent-invoices count (trailing 4 weeks) toneeds_attention_countsso it surfaces even when nobody opens that week. - Tests: RPC-level tests for lock/unlock round-trip incl. void restore; drift-flag fixture (late entry after send flips the badge).
Fable review: it is Fable work. Second-opinion pass by opus on the migration for regressions.
PR-D — feat/invoice-reminders (model: opus; Fable review on send-side)
Closes ranked leak #3 (no AR chase). Builds on rails that already exist (outbox seam ADR-0035, dead columns facilities.reminder_schedule / email_template_reminder / email_template_overdue). Amended per D5: the email is a consolidated per-facility statement, not one email per invoice.
- Define
reminder_scheduleshape:{ "days_after_due": [3, 10, 21] }(company default insettings, per-facility override in the existing column). Migration seeds the default. - New cron route
/api/cron/invoice-reminders(mirroraccrue-interestroute pattern +CRON_SHARED_SECRET). Overdue is always DERIVED fromdue_date+ unpaid balance — never trust the stored status. - Consolidated statement email, one per facility per tick that has newly-due stages — two sections:
- Section 1 — Overdue, payment requested: table of overdue invoices (number, period, amount, balance, days late) + total due now, worded as a payment request. Interest warning included ONLY when
facilities.interest_enabled("per the contract, N%/mo late interest applies; accrued to date: $X" frominterest_accrued_cents). No interest sentence at all for facilities without contractual interest. - Section 2 — Coming due (unpaid, not yet due): number, amount, due date — "how much and when," so one email is the facility's full open-balance picture.
Early stages (due+3) use
email_template_remindertone; the final stage (due+21) usesemail_template_overdue.
- Section 1 — Overdue, payment requested: table of overdue invoices (number, period, amount, balance, days late) + total due now, worded as a payment request. Interest warning included ONLY when
- Idempotency stays per invoice per stage (
invoice_reminders(invoice_id, stage, sent_at)table): a cron tick collects all invoices hitting an unfired stage, groups by facility, sends ONE email per facility covering everything open, then marks all triggered stages fired. Same invoice-stage never fires twice; consolidation never suppresses a stage. - Recipients: billing contacts (else admin), CC
cc_on_invoicecontacts — mirror the invoice send path. Record inoutbound_emailswithkind: 'reminder'. - Facility settings UI: expose the two templates + schedule override on the facility form.
- Wire the cron: add the
cron.schedulestatement todocs/cron-setup.mdand run it in prod (per Step 0 learnings). - Tests: stage idempotency, consolidation grouping (2 overdue + 1 coming-due = one email, both sections), interest sentence gated on
interest_enabled, overdue derivation, token fills.
Fable review: yes (outbound money-adjacent email + new table).
PR-E — fix/mid-week-assignment-hours (model: Fable — REDO of the core picker)
Fixes A1 (mid-week assignment change silently drops hours / re-prices the week). Scope per Decision D3; recommended: minimal version:
- Keep
pickLatestPerContractorfor rate/contracted-hours terms, but sumtime_entriesacross all of the contractor's in-window assignment ids at that facility (not just the picked one) in all four sites:generate.ts:261-267,weeklyRun.tsshelf query,weekly_billing_runSQL (entry_hoursjoin), and the facility-shelf activity map. Hours can no longer vanish; the latest terms still price the whole week (visible, policy-consistent, and what the current code intends). - Migration for the SQL function; TS changes mirrored; the audit's note that
pickLatestPerContractoris reimplemented 4× — consolidate to one shared helper while touching it (src/lib/invoice/pure function + SQL stays SQL). - Tests: fixture with an assignment superseded mid-week (entries on both ids) — invoice bills all hours; rate change mid-week prices at latest rate; no-change weeks byte-identical output (regression guard).
- Full per-segment pricing (each entry billed at its own assignment's rate) only if D3 chooses it — bigger blast radius: line model, PDF description, variance basis.
Fable review: it is Fable work + opus second-opinion on the migration.
PR-F — feat/gap-diagnostics (model: opus)
B1/B4/B5 — make the gap flag diagnostic instead of binary.
- Cause hints: when a row is
gap, annotate why-candidates — contractor has entries at another facility this week (wrong-facility likely), last Hubstaff sync reported skipped members (mapping likely), else true zero. Extend the run RPC or do a cheap follow-up query server-side. - Unblock covered lines (Decision D4): allow Generate for a gap facility — the engine already handles $0 lines with an under-contract reason; the block is purely the UI
canGenerate(InvoicingCockpit.tsx:33-39). Gate behind the same confirmDanger flow with the gap named explicitly. - Gap streak: per-assignment consecutive-gap-week count (SQL window over prior runs or a computed query); after 3+, nudge "end-date this assignment?".
- Leave-window honesty (B5): replace binary leave silencing with a coverage comparison — leave days overlapping the week vs 0-hour weekdays; partial coverage renders "leave (partial)" instead of silencing.
- Tests: each hint path, streak counting, partial-leave fixture.
Fable review: yes for the weekly_billing_run migration portion.
PR-G — fix/correction-guards (model: sonnet; Fable review on the RPC param)
A4 + A6 + A8 + E-routing/dead-end — small hardening batch.
correctInvoiceguard —invoiceManage.ts:258-273: refuseorigin='app'invoices that aren'tdraft(point at Correct/reissue). One conditional + test.- Per-invoice acknowledge —
send_invoicestakesp_acknowledged_ids uuid[]instead of one boolean;ReviewAndSendDialogpasses exactly the checked-with-unapproved ids. Migration + action + dialog change. - Due date at send — in
send_invoices, re-stampdue_date = now() + facility termswhen flipping to sent; then best-effort re-render the PDF post-send inperformSendbefore the email attach when the due date moved (machinery exists:renderAndStoreInvoicePdf). Decision D6 confirms. - Gap-row routing + shelf confirm —
openShelflets a gap row reach the invoice shelf (Log hours lives there), andInvoiceShelf.generatehandlesneedsConfirmwith the sameconfirmDangerdialog the cockpit uses instead of a dead-end error. - Tests: guard rejection, per-invoice ack matrix (checked/unchecked × approved/unapproved), due-date restamp.
Fable review: yes (send RPC + due-date changes are money path).
PR-H — feat/facility-invoice-history (model: sonnet; Fable review — touches weeklyRun service)
New requirement 2026-07-06: "a section in invoicing showing all invoices sent to a selected facility with a status."
Where it lives (recommendation): two connected surfaces, no new page —
- Facility shelf gains an "Invoices" section (
FacilityShelf.tsx, below Contract): the facility's last 6 invoices — number, period, amount, and a status pill using the DERIVED status (effectiveInvoiceStatus, so overdue shows as overdue, not "sent") plus balance when partial. Header line: "Outstanding: $X across N open invoices." Data: extendfetchFacilityShelfwith one query overinvoicesfor the facility (no new RPC needed). - "All invoices for this facility →" link at the bottom of that section, deep-linking to the All-invoices tab with a new
?facility=<id>filter. Implementation: addfacilityto the existing in-memory filter chain inpage.tsx(same pattern as?filter=overdue) + a facility dropdown in the FilterBox row.
Why here and not a new page: the facility shelf is already the "who is this facility" panel one click from every cockpit row; AR history is facility master-data. The All-invoices tab already has the full table, filters, and per-invoice actions — it just can't be scoped to one facility yet. This adds zero new navigation concepts.
How it looks (shelf section sketch):
INVOICES Outstanding: $12,400 · 3 open
INV-1041 Jun 23–29 $4,200 ● Overdue (12d)
INV-1044 Jun 30–Jul 6 $4,100 ● Sent · due Jul 21
INV-1038 Jun 16–22 $4,100 ● Paid
INV-1035 Jun 9–15 $4,100 ● Paid
All invoices for this facility →
Tests: derived-status pill mapping, facility filter param.
PR-I — refactor/single-generate-path (model: sonnet) — D7 resolved: delete
New requirement 2026-07-06: review the Create-Invoice ("Generate an invoice") design to minimize confusion and reduce friction.
Findings on the current form (src/components/admin/InvoicePreviewAndGenerate.tsx, "All invoices" tab):
- It fully duplicates the cockpit path (facility + week → preview → generate) with no unique capability: the cockpit reaches any week via the PeriodBar/?week=, the engine applies the same rules, and the
expectedTotalCentslock is matched in safety by the cockpit's immediate-generate-from-live-data. - It is actively stale: user-facing "Week (Sun–Sat)" label (
:123) and "this Sun–Sat week" copy (:222) on a Monday-anchored week; success message says "Refresh to see it listed below, then download and send it" (:302-304) — manual refresh + send guidance pointing away from Review & send. - Friction: 6+ interactions for one facility vs. the cockpit's batch flow; two parallel mental models for the same act ("which one do I use?").
Resolved (D7): delete it. Remove the "Generate an invoice" section from the All-invoices tab; replace with one line: "Create invoices from the Weekly run tab →" (link that switches tab, preserving ?week=). Delete InvoicePreviewAndGenerate.tsx and prune its now-unused server actions (previewInvoice/generateInvoice in src/server/actions/invoices.ts) IF nothing else imports them — grep first; keep them if shared. One generation path = one mental model, and the deletion removes the stale "Sun–Sat" copy for free. The per-line-reason rigor it had is preserved by the cockpit's confirm-with-audit-reason flow (and PR-F's gap work).
Tests: tab link preserves week param; no dangling imports after deletion.
Runbook — docs/runbook-weekly-billing.md (model: opus drafts, Oliver edits)
The Part C single-person mitigation. Contents: the weekly rhythm (Mon: Hubstaff sync → approve → generate → Review & send; daily: dashboard AR glance; on check: upload → reconcile), the escalation ladder once PR-D ships, prod access notes (SSM/env/cron verification commands), and "if Oliver is unavailable" minimum steps. Docs-only PR, no review gate beyond Oliver reading it.
Decision log (resolved 2026-07-06, except D7)
- D1 (PR-B): ✅ Owner-click re-request (no auto-email to facilities).
- D2 (PR-D): ✅ Default cadence
due+3/due+10/due+21accepted. - D3 (PR-E): ✅ Minimal scope — never drop hours; latest terms price the week.
- D4 (PR-F): ✅ Allowed — generate with the gapped line at $0 + recorded reason.
- D5 (PR-D): ✅ AMENDED — interest mentioned in reminders; overdue stage is a payment-request email with the interest warning ONLY when contractual (
interest_enabled); email includes a separate "coming due" section (unpaid, not yet due: amount + date). No formal interest billing for now. - D6 (PR-G): ✅ Confirmed — due date stamps at send; PDF re-rendered if it moved.
- D7 (PR-I): ✅ Delete the duplicate "Generate an invoice" form; replace with a "Create invoices from the Weekly run tab →" link (preserving
?week=).
Model assignment summary
| Work | Model | Why |
|---|---|---|
| Copy/labels/badges (PR-A) | sonnet | Mechanical UI, no money path |
| Overage aging + KPI (PR-B) | opus | Server actions + SQL aggregate; judgment needed |
| Send-lock + drift (PR-C) | Fable | Rewrites the send RPC — the money-atomic core |
| Reminders cron (PR-D) | opus | New subsystem on existing rails |
| Assignment picker (PR-E) | Fable | REDO of core billing math across 4 sites + migration |
| Gap diagnostics (PR-F) | opus | Run-RPC changes + UI, moderate |
| Correction guards (PR-G) | sonnet | Small diffs; Fable reviews the RPC param change |
| Facility invoice history (PR-H) | sonnet | Display + one query; Fable review (touches weeklyRun service) |
| Single generate path (PR-I) | sonnet | Deletion + link (pending D7) |
| Runbook | opus | Writing, not code |
Standing rule: every PR touching src/db/migrations/, src/lib/invoice/, or src/server/services/invoice/ gets a Fable review pass before merge, regardless of who wrote it.
Sequencing & effort ballpark
Step 0 (today, ~15 min ops) → PR-A → PR-I (small, pairs with A once D7 lands) → PR-C → PR-B → PR-D → PR-H (natural follow-on to D: the AR view for the AR chase) → PR-E → PR-F → PR-G → Runbook. Each PR is independently shippable; C/B/D are the money-recovery core. Rough shape: A/G/H/I small, B/D/F medium, C/E large-careful.
Step 0 results (fill in)
- cron.job verified: …
- RESEND env verified: …
- review + plan committed: …