36. Invoicing cockpit: weekly run, soft-gated send + entry lock
Date: 2026-06-16
Status
Accepted
Context
/admin/invoices already generated facility invoices from contracted hours (migrations 0015/0016:
create_facility_invoice, void_invoice, PDF render + storage), but only as a one-facility-at-a-time
form plus a flat list. There was no weekly cockpit, no facility/invoice drill-down, no batch send, and
sending was a bare status flip (markInvoiceSent) with no emailing and no link from billed time to
the invoice. A handed-off spec proposed billing approved actual hours with an override panel and a
full send pipeline. The owner chose to keep the contracted-hours engine and instead build the
cockpit + send/email/lock on top of it.
Decision
- Billing model is unchanged: contracted hours bill; logged hours only reconcile. The amount on an
invoice is still
Σ (assignment.weekly_contracted_hours × hourly_rate_cents)(the existingbuildInvoicePreview/create_facility_invoice). Logged hours drive the variance flag and the cockpit's reconciliation view; they never silently change the billed amount. No actual-hours override panel, no overage-approval token flow, and migrations 3.2/3.3 from the spec were not built (nobilling_basis/override_reason; retainers don't exist in the data — verified — so nobilling_model/retainer_amount_cents). - Sending is soft-gated by approval, per contractor (assignment) per facility. If any logged hours
in the week are not yet
approved/locked(i.e.submitted/pending_approval), the facility is marked Needs review and held back from the batch by default — but the owner can acknowledge and send anyway (send_invoices(..., p_acknowledge_unapproved => true)). A hard hold (never sendable) applies only when there is no active billing contact, or a bill rate is missing. - One read function,
weekly_billing_run(company, ps, pe)(SECURITY DEFINER, service-role): one row per active facility with contracted/logged/approved/unapproved hours, the contracted-hours billable amount, coverage-gap (computed per assignment then rolled up), variance, and the derived status (sent/gap/review/none/ready). Keeps the status/gap logic in one tested place. send_invoices(company, ids, actor, ack)(SECURITY DEFINER, service-role) is the DB-atomic part: per invoice it takesSELECT … FOR UPDATE(serializes concurrent sends), is idempotent (already sent/paid/void → skipped, never re-numbers — the number was issued at draft bycreate_facility_invoice), flipsdraft → sent, locks the week'sapprovedentries to the invoice (time_entries.status = 'locked',invoice_id— migration 0045), and auditsinvoice_sent. It returns{ sent, held, skipped }— held/skipped are reported, never silently dropped.- Email delivery is the caller's job, via the outbox — the
sendInvoicesserver action renders the invoice email and calls the existingsendEmailseam (ADR-0035), recording every attempt inoutbound_emails. This keeps the money-atomic transaction separate from best-effort delivery: a failed/skippedemail never rolls back a sent invoice; it's visible in the outbox. UntilRESEND_API_KEY+EMAIL_FROMare set, sends are marked sent and logged asskipped. - The lock is reversible.
void_invoicenow re-opens the entries it locked (locked → approved,invoice_id = null). ABEFORE UPDATEtrigger (app.guard_locked_time_entry) freezes alockedentry's billing-relevant fields (hours/date/assignment/contractor) until it's unlocked, so a sent invoice's reconciliation basis can't silently drift — defense in depth beyond the existing RLS that already blocks contractor self-edits of locked rows.
Consequences
- Week convention: Monday–Sunday, not the spec's Sunday–Saturday. The existing engine and all stored
invoices use Monday–Sunday (
recentCompletedWeeks/mondayWeekStart); the cockpit matches them so the run query andcreate_facility_invoiceagree onperiod_start/period_end. Deviating from the spec here was required for consistency with live data. - The cockpit replaces the old
/admin/invoicespage; the previous generate form + flat invoice list are preserved under an "All invoices" tab so no capability is lost. The single-invoice "Mark sent" button there now routes through the sameperformSendservice as the batch path (one sharedsrc/server/services/invoice/send.ts), so locking + email + audit never diverge between the two; that button has no acknowledge UI, so an unapproved-hours hold surfaces as an error pointing to Review & send. - The run table exports to CSV (
/admin/invoices/export?week=…), mirroring the on-screen rows. - New UI primitive
SlideOver(right-anchored shelf on native<dialog>); facility/invoice shelves fetch their own detail by id. - Coverage-gap is informational/operational (an assigned contractor logged nothing and isn't on leave) — in a contracted-hours model the facility is still billable, so it surfaces in red for review rather than blocking. Variance is a badge, not a status driver; the reconciliation gate is the approval soft-gate.
- Deferred: PDF email attachment (the seam is body-only today), and the spec's actual-hours/override and overage-token flows (only needed if the billing model changes).