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(ininclude/bill_formatter_handler.hpp) is the timer-drivenACE_Event_Handlerregistered whenmodules.bill_formatter = 1. It polls the database for unformatted bills and dispatches per-bill render jobs onto the thread pool.BillFormatter(ininclude/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.htmlandpostpaid.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 bybilling_idwhen present. - Postpaid:
call_history— array of CDRs whosebilling_idmatches 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:
- Reads the subscriber's
address.county_id; falls back to-1(the platform-wide default) when the field is missing or zero. - Resolves the
REF_Taxrow via the in-memoryREFDATA::getTax(county_id)cache. The fallback chain is per-county → default (-1) → no-tax. - Computes
subtotal = svc1+svc2+svc3+svc4 + debt, thentax_amount = round_half_away_from_zero(subtotal * rate, 2dp)andtotal_with_tax = subtotal + tax_amount. - Writes all four fields onto the bill row in the same
INSERTthat 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 buterror=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.