Skip to content

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:

  1. Calls render_invoice_pdf to produce the attachment.
  2. Picks a subject line based on invoice_type ("Invoice 2026-000123", "Credit note 2026-000123", etc.) with locale-aware text.
  3. Builds the email body with the same template set as the PDF.
  4. Hands the resulting Notification to the notification registry, which routes it through the configured email provider (SendGrid, SES, SMTP).
  5. 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.