Skip to content

Invoicing

Invoicing is decoupled from billing: the billing handler produces billing::Bill rows; a separate bill_formatter role renders each row to a PDF. The split lets PDF generation scale independently and keeps the heavy templating dependencies (Inja, plutobook) out of the latency-sensitive prepaid and rating paths.

Roles and Components

  • BillFormatterHandler (in include/bill_formatter_handler.hpp) is the timer-driven ACE_Event_Handler registered when modules.bill_formatter = 1. It polls the database for unformatted bills and dispatches per-bill render jobs onto the thread pool.
  • BillFormatter (in include/bill_formatter.hpp) is a pure rendering helper. It takes a template directory, an Inja-rendered HTML template name, a JSON context, and an output path; it returns true on success.
  • The HTML templates live under bill_formatter.template_dir (default ./html). Two templates are required: prepaid.html and postpaid.html. They are Inja templates with the same JSON context schema; differences in the rendered output (e.g. balance history vs. call history) come from the data passed in.
  • The output PDFs are written under bill_formatter.output_dir (default ./invoices), partitioned by year, month, and bill-cycle: invoices/<year>/<month>/bc_<billcycle>/<billing_id>.pdf.

Formatting Pipeline

sequenceDiagram
    autonumber
    participant Reactor
    participant H as BillFormatterHandler
    participant Pool as ThreadPool
    participant DB as MySQL
    participant Fmt as BillFormatter
    participant Inja
    participant PB as plutobook
    participant FS as invoices/...
    participant Notif as notifications

    Reactor->>H: handle_timeout()
    H->>Pool: drop completed futures
    H->>DB: getUnformattedBills()
    DB-->>H: [bill_id, ...]
    loop per bill_id
        H->>Pool: enqueue(processBill(bill_id))
    end

    Pool->>DB: lockBillForFormatting(bill_id)
    alt acquired
        DB-->>Pool: bill, sub
        alt sub.prepaid == 1
            Pool->>DB: getPrepaidBalanceHistory(sub_id, m, y)
            Pool->>DB: getUsageHistory("online", sub_id, m, y, billing_id)
        else postpaid
            Pool->>DB: getUsageHistory("offline", sub_id, m, y, billing_id)
        end
        Pool->>Fmt: generatePdf(template_file, json, output_filename)
        Fmt->>Inja: env.render_file(template_path, context)
        Inja-->>Fmt: HTML
        Fmt->>PB: Book(A4).loadHtml().writeToPdf()
        PB->>FS: <billing_id>.pdf
        Fmt-->>Pool: ok
        alt prepaid
            Pool->>DB: commitBillFormatted(bill_id, BILL_STATUS_PAID, url)
        else postpaid
            Pool->>DB: commitBillFormatted(bill_id, BILL_STATUS_UNPAID, url)
        end
        Pool->>DB: updateEventStatusToBilled(billing_id)
        Pool->>Notif: insert("Invoice generated for ...")
    else not acquired
        Note over Pool: Skip — owned by another worker
    end

lockBillForFormatting performs an atomic transition from BILL_STATUS_NEW to BILL_STATUS_PROCESSING. Because the transition is atomic, multiple bill-formatter processes can run in parallel without double-rendering: only the worker that observes the transition acquires the bill. On a render failure, rollbackBillFormatting returns the row to BILL_STATUS_NEW.

JSON Context

The JSON passed to Inja contains the same top-level keys for both subscriber types:

{
  "bill_id":         "<billing_id>",
  "msisdn":          "<MSISDN>",
  "debt":            "<symbol><formatted>",
  "month":           <int>,
  "year":            <int>,
  "date":            "<formatted local datetime>",
  "comment":         "<bill comment>",
  "svc1_cost":       "<symbol><formatted>",
  "svc2_cost":       "<symbol><formatted>",
  "svc3_cost":       "<symbol><formatted>",
  "svc4_cost":       "<symbol><formatted>",
  "subtotal":        "<symbol><formatted>",
  "tax_rate":        <float>,
  "tax_rate_pct":    "<percent>",
  "tax_amount":      "<symbol><formatted>",
  "total_with_tax":  "<symbol><formatted>",
  "total_amount":    "<symbol><formatted>",
  "currency_code":   "<ISO 4217>",
  "reissue_count":   <int>,
  "last_reissue_date": "<formatted local datetime>"
}

Subscriber-type-specific extensions:

  • Prepaid: balance_history — array of voucher / top-up events for the period; call_history — array of online-rated charge events for the bill, filtered by billing_id when present.
  • Postpaid: call_history — array of CDRs whose billing_id matches this bill.

Every monetary value is formatted at render time in the subscriber's display currency (see Multi-Currency Display). Templates print the tax_* / subtotal / total_with_tax block only when tax_rate is non-zero; bills that predate the VAT layer keep their original single-line total_amount rendering. The reissue_count / last_reissue_date pair is surfaced only when the bill has been regenerated at least once (see Re-issued Invoices).

VAT / Tax

Each bill row carries tax_rate, tax_amount, total_with_tax, and tax_id. These are stamped at bill-creation time by DB_layer::stampTaxForBill(bill, subscriber), which:

  1. Reads the subscriber's address.county_id; falls back to -1 (the platform-wide default) when the field is missing or zero.
  2. Resolves the REF_Tax row via the in-memory REFDATA::getTax(county_id) cache. The fallback chain is per-county → default (-1) → no-tax.
  3. Computes subtotal = svc1+svc2+svc3+svc4 + debt, then tax_amount = round_half_away_from_zero(subtotal * rate, 2dp) and total_with_tax = subtotal + tax_amount.
  4. Writes all four fields onto the bill row in the same INSERT that creates it.

The PDF template renders a three-line VAT block — Subtotal, VAT (rate%), TOTAL — when tax_rate > 0. Adding a new per-county rate is a single INSERT INTO ref_tax (county_id, name, rate) VALUES (...) — no application-code change required.

Multi-Currency Display

Every monetary value persisted by the platform — CDR final_price, balance, bill totals, VAT, debt — is in EUR. The display currency is a per-subscriber attribute (Subscriber.currency_id) and is consumed only at render time. At the top of BillFormatterHandler::processBill, the formatter resolves REFDATA::getCurrency(sub.currency_id()) once and then threads the (rate_from_eur, symbol) pair through every call to Utils::formatCurrency(float amount_eur, float rate, const std::string &symbol). The two history builders (getPrepaidBalanceHistory, getUsageHistory) accept the same pair so per-event amounts inside balance_history and call_history arrive at the template already converted.

The ref_currency reference table is the single source of truth. id=1 is the EUR anchor (rate_from_eur = 1.0) and must remain so — subscribers whose currency_id is 0 or points at a missing row fall back to it. Adding a new currency is a single INSERT INTO ref_currency (code, name, symbol, rate_from_eur) VALUES (...) — the CRM frontend picks it up via GetRefData and offers it in the per-subscriber currency picker.

Re-issued Invoices

Bills can be regenerated through the ReformatBill CRM RPC. The server atomically transitions the row from a terminal status (PAID, UNPAID, or ERROR) back to BILL_STATUS_NEW, increments reissue_count, and stamps last_reissue_ts with the current UNIX seconds. The bill formatter picks the row up on its next tick exactly as it would a fresh bill and overwrites the PDF at the same path. Templates print a "Reissued: <date> (#<n>)" line only when reissue_count > 0 so pristine bills are unchanged.

Reissues are gated: a request against a bill already in NEW or PROCESSING is rejected (the existing render must finish first). On success the operator notification carries the deep-link to the invoice page so the regenerated PDF is one click away.

Output Path Layout

<bill_formatter.output_dir>/
├── 2026/
│   ├── 01/
│   │   ├── bc_1/
│   │   │   ├── ABCDEF...pdf
│   │   │   └── ...
│   │   ├── bc_2/
│   │   ├── bc_3/
│   │   └── bc_4/
│   └── 02/
└── ...

Year and month come from the bill's billed_year and billed_month; the bill-cycle directory comes from bill.billcycle(). The handler creates intermediate directories with std::filesystem::create_directories before rendering.

Rendering Backend

plutobook is invoked with A4 page size:

plutobook::Book book(plutobook::PageSize::A4);
book.loadHtml(html_content, "", "", template_dir_ + "/");
book.writeToPdf(output_pdf_file);

The third argument to loadHtml is the base URL for resolving relative asset references in the template (CSS, images). It is set to the template directory so logos, fonts, and stylesheets shipped alongside the templates resolve correctly.

Status Transitions

Stage Bill.status_id
Inserted by BillingHandler::billOne / billCycle BILL_STATUS_NEW (1)
Acquired by formatter BILL_STATUS_PROCESSING (2)
Render success, prepaid BILL_STATUS_PAID (5)
Render success, postpaid BILL_STATUS_UNPAID (3)
Render failure, rolled back BILL_STATUS_NEW (1)
Hard error (no rollback path) BILL_STATUS_ERROR (4)
Operator reissue via ReformatBill BILL_STATUS_NEW (1) — re-enters the cycle

After a successful render, every CDR included in the bill (matched by billing_id) is advanced to EVENT_STATUS_BILLED. This step is non-fatal: if it fails, the PDF is still committed and the failure is logged — rerunning the update is safe and idempotent. updateEventStatusToBilled is also idempotent on a reissue: every CDR with the matching billing_id is already EVENT_STATUS_BILLED, so the second update is a no-op.

Notification Hooks

Each terminal outcome inserts a billing::Notification:

  • Success: "Invoice generated for <subscriber name>", URL ?page=invoice&id=<bill_id>.
  • Failure: "Failed to generate bill PDF for <subscriber name>", same URL but error=NOTIFICATION_NOT_OK.

The CRM polls notifications via GetNotifications and surfaces them on the operator dashboard, where clicking the notification navigates directly to the bill page.