← Back

Patch notes — native billing subsystem (2026-06-15, commit `dd867f7`)

A complete native invoicing subsystem for Ethica.no — predefined products, manual time tracking on tickets & projects, MVA-compliant invoices with a real PDF emailed over our own SMTP, outstanding/invoiced metrics — behind a new Billing access group. Built into server.py (like the newsletter engine); no separate service. Shipped in one drop, Docker smoke 141 / 0. Design: ../../ethica-no/billing/spec.md.


1 — The Billing group ✅

can_bill(user)admins always, or a staff user explicitly granted billing (Settings → Billing → “Billing staff”). Customers never. It gates every /admin/billing*, /admin/product*, /admin/invoice* and /admin/time* route, the company Billing tab, and the settings writes — enforced server-side, not just hidden in the menu (the sidebar item hides via /api/me’s new can_bill flag).

2 — Products ✅

A predefined catalog (/admin/billing/products): name, SKU, unit (hour/item), price, VAT. Used as invoice line items and as the rate source for time entries. Seeded with Consulting / Support hour / Managed service.

3 — Time tracking ✅

A “Log time” box on ticket and project detail (and the company Billing tab): hours, date, description, billable flag, rate from a product or the configured default. Unbilled billable time is pulled into invoices.

4 — Invoices ✅

Create a draft (no number) on a company’s Billing tab → add lines from unbilled time, products, or manual entry → Issue & send:

  • allocates the next gap-free YYYY-NNNN number (assigned only at issue, so deleted drafts never leave holes — bokføringsloven);
  • computes net / VAT (25%) / gross;
  • freezes a PDF (fpdf2) — an immutable snapshot of the issued document;
  • emails it as a PDF attachment to the company’s billing contact over our own SMTP;
  • marks the linked time billed.

Then Mark paid (manual — air-gapped, no gateway), Cancel/void (frees the hours again, keeps the number), or Delete (drafts only). Overdue is computed, not stored.

5 — Metrics ✅

/admin/billing overview: utestående (sent-unpaid) and fakturert (issued) per week / month / year, an overdue count, open drafts, and recent invoices.

6 — Customer portal ✅

When the per-company billing app is toggled on (company → Applications tab), the customer sees an Invoices list in their portal and can download their own PDFs via an access-scoped route (/account/invoice/pdf — own company + issued only; another company’s invoice → 403).

7 — New dependency

fpdf2 (LGPL-3.0) — pure-Python PDF generation, no system libraries, so it stays air-gappable (WeasyPrint’s cairo/pango were rejected). Added to the Dockerfile; the import is guarded so a missing dep degrades gracefully. Tracked in the third-party license list alongside the other GPL/LGPL components (e.g. SOGo).

Schema

New tables (idempotent, no semicolons in SCHEMA comments): products, time_entries, invoices, invoice_lines, billing_counters, billing_access; plus crm_companies.billing_enabled. All mutations CSRF-checked and audited (time.log, invoice.issue/paid/cancel, billing.access.*).

Verification

py_compile clean · Docker smoke 141 / 0 — incl. capability gating (non-billing staff → 403, then granted → 200), product CRUD, log time, draft → issue assigns a gap-free number, PDF frozen + served as %PDF, mark-paid, and a customer seeing only their own invoice (cross-company PDF → 403).