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-NNNNnumber (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).