Invoicing¶
The invoicing subsystem issues, renders, and delivers invoices in four directions: system → tenant (service fees and self-billing), tenant → customer (customer charges), and credit notes in either direction. Every invoice is a first-class document with line items, VAT, a PDF renderer, and an email delivery pipeline. The model enforces direction correctness at validation time, so you cannot accidentally issue a self-billing invoice from a customer to a tenant.
Invoice directions¶
Four invoice types capture the four legitimate flows:
| Type | Issuer | Recipient | Used for |
|---|---|---|---|
service_fee |
System owner | Tenant | Platform fees billed by the system to a tenant |
customer_charge |
Tenant | Customer | The tenant billing its end-user for goods or services |
self_billing |
System owner | Tenant | The system owner issuing an invoice on behalf of the tenant for commission or royalties (reverse charge) |
credit_note |
Mirrors the original | Mirrors the original | Reverses a previously sent invoice; links to original_invoice_id |
A validator on the model asserts that the issuer_type/recipient_type pair matches the invoice_type. A service_fee invoice with issuer_type="customer" is rejected at insert time — not deep in the PDF renderer, but at the service layer where the mistake is still cheap to fix.
Why four types? Because the accounting treatment, the VAT handling, and the ledger entries differ per direction. A service_fee invoice debits the tenant's platform-fees account; a customer_charge credits the tenant's revenue account. The type tells the ledger which side of which account to touch — see Bookkeeping for the mapping.
The Invoice model¶
craft_easy.models.bookkeeping.Invoice:
| Field | Purpose |
|---|---|
invoice_number |
Human-readable, per-tenant monotonic number (auto-generated) |
invoice_type |
service_fee, customer_charge, self_billing, credit_note |
issuer_name, issuer_org_number, issuer_type |
Issuer metadata |
recipient_name, recipient_email, recipient_org_number, recipient_type |
Recipient metadata |
reference_type, reference_id |
Links back to the originating business object (order, subscription, settlement) |
original_invoice_id |
On credit notes, the invoice being credited |
agreement_id |
Optional link to the tenant's agreement, for traceability |
settlement_id |
Optional link to the settlement this invoice belongs to |
subtotal, vat_rate, vat_amount, total, currency |
Amounts (all Decimal) |
line_items |
List of InvoiceLineItem |
status |
draft, sent, paid, overdue, cancelled, credited |
issue_date, due_date, paid_at, sent_at |
Lifecycle dates |
payment_reference, bankgiro |
Payment info printed on the PDF |
InvoiceLineItem¶
InvoiceLineItem(
description="Professional services — April 2026",
quantity=Decimal("12"),
unit_price=Decimal("1200.00"),
vat_rate=Decimal("0.25"),
total=Decimal("14400.00"),
)
The total is stored explicitly rather than computed on the fly — storing it makes historical invoices immutable even if rounding rules change in a future release. The service layer asserts that the line totals match the header totals on insert.
InvoiceService¶
craft_easy.core.invoicing.service.InvoiceService is the entry point for every invoice operation.
Creating an invoice¶
from craft_easy.core.invoicing.service import InvoiceService
svc = InvoiceService()
invoice = await svc.create_invoice(
invoice_type="customer_charge",
issuer_name="Acme AB",
issuer_org_number="556677-8899",
issuer_type="tenant",
recipient_name="Jane Customer",
recipient_email="jane@example.com",
recipient_type="customer",
reference_type="order",
reference_id=order.id,
line_items=[
InvoiceLineItem(
description="Monthly subscription",
quantity=Decimal("1"),
unit_price=Decimal("499.00"),
vat_rate=Decimal("0.25"),
total=Decimal("499.00"),
)
],
subtotal=Decimal("499.00"),
vat_rate=Decimal("0.25"),
vat_amount=Decimal("124.75"),
total=Decimal("623.75"),
currency="SEK",
issue_date=date.today(),
due_date=date.today() + timedelta(days=30),
)
# invoice.status == "draft"
# invoice.invoice_number == "2026-000123" (auto-generated)
The invoice number is generated per tenant, using a monotonic counter seeded from the year. OCR payment references (numeric, with a Luhn check digit) are generated in the same hook so the PDF can display them.
Self-billing shortcut¶
Self-billing invoices have their direction pinned — system owner to tenant — so a dedicated helper exists to avoid repeating the metadata:
await svc.create_self_billing_invoice(
system_owner_name="Platform Inc",
system_owner_org_number="111111-2222",
tenant_name="Acme AB",
tenant_org_number="556677-8899",
subtotal=Decimal("10000"),
vat_amount=Decimal("0"), # reverse charge, VAT handled by recipient
total=Decimal("10000"),
currency="SEK",
...,
)
Lifecycle transitions¶
# draft -> sent (triggers PDF render + email delivery)
await svc.send_invoice(invoice.id)
# sent -> paid
await svc.mark_paid(invoice.id)
# any non-draft -> credit note
credit = await svc.create_credit_note(
original_invoice_id=invoice.id,
reason="Customer cancelled",
)
# credit.total == -invoice.total
# credit.invoice_type == "credit_note"
# invoice.status == "credited"
create_credit_note creates a new invoice with mirrored parties, negative amounts, a link back to the original, and flips the original's status to credited. That prevents a second credit note from being issued against the same invoice.
PDF rendering¶
craft_easy.core.invoicing.pdf.render_invoice_pdf renders an invoice to PDF bytes:
from craft_easy.core.invoicing.pdf import render_invoice_pdf
pdf_bytes = await render_invoice_pdf(
invoice,
locale="sv", # "sv" or "en"
logo_url="https://.../logo.png",
accent_color="#1E40AF",
footer_text="Acme AB · Storgatan 1 · 11122 Stockholm",
original_invoice_number="2026-000122", # for credit notes
)
The renderer uses Jinja2 templates in core/invoicing/templates/ and WeasyPrint to rasterize. If WeasyPrint is not installed, the renderer returns the rendered HTML instead — useful for tests and for deployments that prefer to print server-side with a different tool.
Localization is minimal: Swedish and English labels ("Faktura" vs "Invoice", "Förfallodatum" vs "Due date"), plus locale-sensitive amount formatting. Adding a language is a matter of adding a labels dict; the templates themselves do not need to change.
Template filters handle amount and percentage formatting with the correct decimal places and thousands separators for the locale. Currency decimals come from core.money.CURRENCIES, so JPY renders with zero decimals while SEK renders with two.
Batch rendering¶
Rendering one PDF at a time is fine for interactive flows (the "Download invoice" button on a tenant dashboard) but too slow for end-of-month batches. For those, run the renderer across all invoices in a period via the batch worker:
for invoice in await Invoice.find({"status": "draft", "issue_date": {"$gte": period_start, "$lt": period_end}}).to_list():
pdf = await render_invoice_pdf(invoice, locale=invoice.locale)
await blob_storage.put(f"invoices/{invoice.id}.pdf", pdf)
The renderer is stateless and concurrency-safe — run as many in parallel as your CPU allows.
Email delivery¶
craft_easy.core.invoicing.delivery.deliver_invoice_email renders the PDF, constructs the email, and sends it through the Craft Easy notification registry:
from craft_easy.core.invoicing.delivery import deliver_invoice_email
ok = await deliver_invoice_email(
invoice,
locale="sv",
logo_url="https://.../logo.png",
accent_color="#1E40AF",
footer_text="Acme AB · Storgatan 1 · 11122 Stockholm",
from_email="invoices@acme.example",
from_name="Acme AB",
)
The function:
- Calls
render_invoice_pdfto produce the attachment. - Picks a subject line based on
invoice_type("Invoice 2026-000123","Credit note 2026-000123", etc.) with locale-aware text. - Builds the email body with the same template set as the PDF.
- Hands the resulting
Notificationto the notification registry, which routes it through the configured email provider (SendGrid, SES, SMTP). - Logs delivery in
NotificationLog.
If no email provider is configured, the function falls back to logging the message instead of silently dropping it — useful during development before a provider is registered.
Calling delivery from the service¶
InvoiceService.send_invoice calls deliver_invoice_email internally and flips the invoice status to sent on success. Application code only needs to call send_invoice; the rest happens automatically.
Routes¶
POST /invoices # create (status=draft)
GET /invoices # list (filter: status, invoice_type, recipient_name, ...)
GET /invoices/{invoice_id} # fetch
PUT /invoices/{invoice_id} # update (email, payment ref, status, bankgiro)
POST /invoices/{invoice_id}/send # render + deliver + mark sent
POST /invoices/{invoice_id}/mark-paid # mark paid
POST /invoices/{invoice_id}/credit-note # create credit note
GET /invoices/{invoice_id}/pdf # download PDF
GET /invoices/{invoice_id}/ocr # get OCR payment reference
GET /invoices/{invoice_id}/pdf returns the PDF bytes with Content-Type: application/pdf. The endpoint is scope-guarded: tenant users can only fetch invoices for their own tenant, customers can only fetch invoices where they are the recipient.
VAT and rounding¶
Every amount is a Decimal. VAT is computed from the line items rather than extracted from a gross total — line totals are exclusive, VAT is added on top, and the header totals are the sum of the lines. This avoids the "off-by-one-öre" bug you get when you try to round the extracted VAT of a gross amount.
If the sum of line VATs does not match the header vat_amount, the service rejects the invoice with 422. Use the helpers in core/money.py to compute VAT from subtotal and rate — they handle rounding with decimal.ROUND_HALF_EVEN to match Swedish accounting conventions.
Read Bookkeeping for how an issued invoice posts to the ledger, and Claims & Collections for what happens when an invoice goes overdue.