Skip to main content

21. Facility invoice generation

Date: 2026-06-06

Status

Accepted

Context

Step 11 generates the weekly USD invoice a facility pays for a contractor's services. The two prior invoices (INV-0990, INV-0991) were produced manually in Wise; the owner provided them as the layout

  • data reference. They are Wise-style: header, "Billed to"/"Issued by", a big " USD due by ", a line table (Quantity / Unit price / Tax / Total), totals, and a "Ways to pay" block with fixed Wise local + international bank details. The schema already had invoices (unique invoice_number, period, total_amount_cents, amount_paid_cents, status incl. partial/void, due_date, pdf_storage_path, variance_flagged) and invoice_line_items (with hours = contracted/billed, actual_hours, hourly_rate_cents, amount_cents, line_type).

Decisions confirmed with the owner: weekly (Mon–Sun) cadence; @react-pdf/renderer for the PDF (over the plan's Puppeteer — no headless-Chromium binary, deterministic, testable, trivial to deploy); replicate the INV-0990/0991 layout exactly.

Decision

  • Bill contracted hours; flag variance. Each line bills the contractor's weekly contracted hours × the USD hourly_rate_cents (integer cents, lib/money). The hours actually worked that week are recorded per line and compared to contracted; a gap strictly greater than the facility's variance_flag_threshold_pct (default 10) flags the line and sets invoices.variance_flagged, surfaced to the owner before sending. The flag never changes the billed amount (the spec's locked basis). One service line per active assignment at the facility; the latest overlapping assignment version per contractor is chosen deterministically (id tiebreak).
  • Sequential numbering from INV-0992, allocated atomically. A next_invoice_number setting (seeded 992) is read-and-incremented under a row lock inside the create_facility_invoice RPC, so concurrent generation can't collide or skip.
  • Atomic creation + double-invoice guard. create_facility_invoice (SECURITY DEFINER, service-role only) allocates the number, inserts the invoice (draft) + line items, and is guarded by an in-transaction check and a partial unique index (company, facility, period_start, period_end) where status <> 'void' — at most one active invoice per facility+week; voiding frees the period to re-issue. An expectedTotalCents field binds the figure the owner confirmed.
  • PDF rendered then stored. After the RPC, the service renders the @react-pdf/renderer document (fixed issuer + Wise bank details from a seeded invoice_config setting; reference = invoice number) to a buffer and uploads it to a private invoices Storage bucket (<facility_id>/<number>.pdf). Downloads go through /admin/invoices/[id]/download, which re-checks the admin and 302s to a 60s signed URL. A render/upload failure leaves the invoice without a PDF (regenerable), never an unpaid gap.
  • No money movement, no email. Generation only drafts the invoice + PDF; the owner downloads and sends it. (Gmail delivery is Step 12.) Owner-only for preview/generate/void; staff can view/download.
  • Audit attribution via the transaction-local app.actor_id GUC (ADR-0020), so create/void are attributed to the owner under the service role.

Consequences

  • The pure engine (lib/invoice) reproduces INV-0990/0991 to the cent and is unit-tested; the real PDF template is render-tested to a valid %PDF. Money stays integer USD cents throughout.
  • Voiding is the correction path (a paid invoice cannot be voided); the partial unique index makes re-issue after void safe and a duplicate active invoice impossible.
  • Deferred / pending:
    • Pay-online QR. The samples carry a Wise hosted-payment QR. We have no hosted-invoice URL yet (Wise is Step 14, draft-only), so payOnlineUrl is a null setting and the QR/"Pay online" column is omitted until configured; the bank-detail columns are the substantive payment method.
    • Logo. logoDataUrl is an optional setting; the header renders the "Invoice" wordmark until a logo is supplied.
    • Cadence. Weekly only for now (matches the contracted-hours model); bi-monthly/monthly would bill weekly × weeks-in-period.
    • Interest / overage / adjustment line types exist in the schema but are out of scope here (interest is Step 15; approved overage lines fold in with Step 9's data when needed).