Skip to main content

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 existing buildInvoicePreview / 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 (no billing_basis/override_reason; retainers don't exist in the data — verified — so no billing_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 takes SELECT … FOR UPDATE (serializes concurrent sends), is idempotent (already sent/paid/void → skipped, never re-numbers — the number was issued at draft by create_facility_invoice), flips draft → sent, locks the week's approved entries to the invoice (time_entries.status = 'locked', invoice_id — migration 0045), and audits invoice_sent. It returns { sent, held, skipped } — held/skipped are reported, never silently dropped.
  • Email delivery is the caller's job, via the outbox — the sendInvoices server action renders the invoice email and calls the existing sendEmail seam (ADR-0035), recording every attempt in outbound_emails. This keeps the money-atomic transaction separate from best-effort delivery: a failed/skipped email never rolls back a sent invoice; it's visible in the outbox. Until RESEND_API_KEY + EMAIL_FROM are set, sends are marked sent and logged as skipped.
  • The lock is reversible. void_invoice now re-opens the entries it locked (locked → approved, invoice_id = null). A BEFORE UPDATE trigger (app.guard_locked_time_entry) freezes a locked entry'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 and create_facility_invoice agree on period_start/period_end. Deviating from the spec here was required for consistency with live data.
  • The cockpit replaces the old /admin/invoices page; 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 same performSend service as the batch path (one shared src/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).