Invoicing#
The Invoicer module issues tax documents — manually from the administration, from an
order, automatically from a payment, and on a schedule. The code lives in
app/UI/Api/Invoicer/, the administration in app/UI/Admin/Invoicer/.
What the module is made of#
| Group | Entities | What for |
|---|---|---|
| the document | Invoice plus the lines InvoiceItem, InvoiceShipper, InvoicePaymentMethod, InvoiceVoucher, InvoiceGift |
the invoice itself; every kind of line has its own table but the same set of monetary fields |
| the template | InvoiceTemplate plus a mirrored InvoiceTemplate* set |
the blueprint for recurring documents |
| the parties | Seller, SellerContact, SellerBankAccount, Customer, CustomerContact |
the seller and the customer |
| code lists | InvoiceType, BillingLine, PaymentMethod, ProductType |
document type, number series, payment method, kind of supply |
| automation | Schedule, PaymentStatusRule |
the recurrence schedule, the "payment state → document type" rule |
On top of that, three generators and one counter-free helper:
| Class | What it does | Where it is called |
|---|---|---|
InvoiceCalculator |
recomputes every monetary field | InvoiceManager::beforeSave(), before the flush |
InvoiceNumberGenerator |
assigns order, periodKey and number |
the same place, on insert only |
InvoiceGenerator |
builds a whole document from a payment (or a credit note from its source) | the cron/invoicer/payment/generate cron |
InvoicePdfGenerator |
produces the PDF and the payment QR code | InvoiceManager::completedSave(), after the commit |
CreditNoteLedger |
computes how much of an invoice has already been credited | the cap check for partial credit notes |
A document is a snapshot, not a reference#
On issue, an invoice copies the seller's and the customer's address, the bank details, the number format and the rates. This is not a normalisation lapse — a tax document has to look forever exactly as it was issued.
Changing the seller's record does not change already issued documents — and vice versa
Fixing only one of the two is the most common mistake in this module. Fix the company number on the seller record and the old invoices still carry the original one; fix it on the invoice and the next invoice comes out with the old value again. Ask every time whether you are fixing future documents or one specific one.
Procedure: issuing a document from code#
- Assemble an
Invoice— the parties, the currency, the issue date and the lines. - Do not fill the monetary fields. Whatever you put there,
InvoiceCalculatoroverwrites inbeforeSave(). The inputs are only the price, quantity, VAT rate, discount and theuseVat/useDiscountswitches. - Set the number only when importing. Normally leave
numberempty and fill inbillingLineId— the number is assigned by the series. - Save through
InvoiceManager::save(). The recalculation, the numbering and the PDF hook themselves in. - Check
isSuccess()— a failure carries a specific code (missing series, duplicate number, broken XOR).
A manual number and a series are mutually exclusive (XOR)
Sending both at once fails with VALIDATION_FAILED. A document with a manual number
carries order = 0 and an empty periodKey; a second one in the same series would
violate the uq_invoice_sequence unique index. Manual numbers are reserved for
import and migration; the admin form does not offer them at all.
Numbering#
A series (BillingLine) carries a mask, a reset period and a digit count.
The generator derives from it:
| Field | How it is produced |
|---|---|
periodKey |
the period key from the series' billing_period (year, month, …) |
order |
MAX(order) + 1 within the (series, period) pair |
number |
the mask with %Y, %m, %d (from the issue date) and %number (zero-padded to number_length) substituted |
variableSymbol |
the digits from the number, unless it was entered manually |
The critical section is serialised by a SELECT … FOR UPDATE on the series row and
is held until the commit; the schema-level safety net is
UNIQUE(billing_line_id, order, period_key).
A mask without %number breaks the second invoice in a period
The first document is issued; the second composes the same number. The generator
tries to bump the order, but without %number the result does not change — it ends
the loop and the backstop in the manager returns DB_DUPLICATE_ENTRY. It looks like
a saving error, but it is a misconfigured series.
The issue date drives the number, so backdating produces a different sequence
%Y/%m/%d are taken from the issue date, and the period key is computed from it
too. Changing the date on an unissued document therefore changes which sequence it
falls into — and that is invisible until the document is saved.
Gaps appear in the sequence and that is correct
A manually imported invoice has a number but does not raise MAX(order) in the
series (it has no series). The generator works around the collision by bumping the
order — the taken slot stays as a gap. Anyone who "fixes" that gap creates a
duplicate.
Recalculating the amounts#
The server is the authority: invoice-calc.js only mirrors the same arithmetic for the
sake of the UI. All three kinds of line (item, shipping, payment method) share an
identical set of fields.
per line
vatPercent = useVat ? (vat ?? 0) : 0
discountPercent = useDiscount ? (discount ?? 0) : 0
priceAfterDiscount = price − price · discountPercent/100
totalPrice = priceAfterDiscount · quantity
totalPriceWithVat = priceAfterDiscount · (1 + vatPercent/100) · quantity
vatInMoney = totalPriceWithVat − totalPrice
header
base = Σ line.totalPrice (items + shipping + payments)
baseVat = Σ line.totalPriceWithVat
totalPrice = base − Σ voucher discount
totalPriceWithVat = baseVat − Σ voucher discount with VAT
The header's useVat dominates the line rates
When it is off, every line's rate counts as zero — the PDF will have no VAT breakdown even though the values are in the data. A non-VAT-payer thus gets the correct document, but anyone who overlooks the switch sees "the VAT is missing" and goes looking in the template.
Client numbers are not trusted
item.totalPrice and header.totalPrice submitted by a form are not saved —
they are overwritten by the recalculation. There is no point sending them in an Api
call and no point debugging why a value "was not saved" when the server computes it.
Rounding is switched off for now
roundedTotal* = total* and rounding* = 0. The fields exist and the PDF template
reads them, but whole-unit rounding is not performed yet.
Credit notes#
A credit note is not a flag on an invoice. It is a document type with the
isCreditNote() flag; the resulting document is a mirror of its source with negative
amounts and a reference back to it (relatedInvoiceId).
The cap on partial credit notes is guarded by CreditNoteLedger, which holds a single
invariant:
for every line of the source invoice, Σ credited units ≤ invoiced units
| Aspect | How it is handled |
|---|---|
| the remainder | computed from the existing credit notes, not kept as a counter |
| the key | the id of the source invoice line, never order_item_id (which is not unique) |
| a voucher | keyed by amount, not by units — a fixed discount is consumed in parts |
| concurrency | the ledger holds no lock itself; the check and the write must happen inside the source lock |
A remainder counter would drift the first time a document was deleted
That is why it is computed. Anyone who "optimises" it into a stored number gets a document that can be credited more than once.
A credit note line without a link to its source must not be ignored
An old document that could not be paired means we do not know which source line it consumed. The cap on such an invoice cannot be trusted — the caller has to reject it, not skip it.
One payment may have several documents
A regular invoice and a credit note. There is therefore no unique index on
Invoice.paymentId; idempotence is held by checking for an existing document of
that type plus advancing Payment.invoicedStatus.
PDF and the payment QR code#
They are produced in completedSave() — that is, after the commit, outside the save
transaction. The pdf and qrCode columns are in defaultSkipFields, so a regular
object-graph save skips them; their only writer is a direct updateColumns() after the
commit.
The generator is best-effort: it never throws, a partial failure is only logged and
returns null.
Without a PDF variant no file is produced at all, and nobody is told
The variant is chosen by a matrix of kind of supply × payment region × user type. When nothing matches the combination, the invoice saves fine, just without a file. There is no error anywhere — you find out when somebody asks for the PDF.
Regenerating clears the document's folder first
Files added by hand into the invoice folder disappear. That is also why null is
written into pdf/qrCode — after a failed regeneration the old path would point at
a deleted file.
Automation: schedules and payments#
| Task | Endpoint | How often | Switch |
|---|---|---|---|
| schedules | /cron/invoicer/schedule/generate |
daily | — |
| documents from payments | /cron/invoicer/payment/generate |
often (e.g. every 15 min) | 7/enabledForPayments |
Both work in batches (7/scheduleBatchSize, 7/paymentBatchSize, 10 by default).
A schedule generates from a template or by cloning an invoice and advances the next run date only after a successful save — which is why re-running it is safe.
Documents from payments follow the PaymentStatusRule rules (payment state → one
document type). The generation is deliberately decoupled from the payment callback so
that it does not delay the response to the gateway. Errors fall into two kinds:
| Kind of error | What happens | Why |
|---|---|---|
| transient (configuration, save failure) | invoicedStatus is not advanced, the payment is retried next time |
next time it may work |
permanent (DB_NOT_FOUND — an orphaned rule, a missing credit-note source) |
the payment is settled without a document and recorded in the errors | a retry would never succeed and the payment would permanently eat the batch |
A permanent error means a payment without an invoice — and the system lets it through
That is deliberate: one unresolvable payment would otherwise starve the whole batch and none of the others would be issued either. The consequence, though, is that the error report has to be checked — otherwise a paid order stays without a document and nobody finds out.
A currency with invoicing switched off is settled with no document and no error
Credits and similar internal currencies get no document. The payment is marked as settled so it does not return to the queue on every run — it will therefore not be in the error report.
A schedule without a series is silently skipped
Adding the series fixes it by itself, but the missed date does not come back.
Saving collections replaces them#
Fields the form does not render have to be carried over explicitly
Saving an object graph deletes missing lines — see Hydrators. On an invoice this also has a monetary consequence: the lost lines shift the cap for partial credit notes, so more can then be credited than was ever invoiced.
Where to look#
| I want | Where |
|---|---|
| the amount recalculation | app/UI/Api/Invoicer/Models/Helpers/InvoiceCalculator.php |
| the numbering | app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php |
| building a document from a payment | app/UI/Api/Invoicer/Models/Generators/InvoiceGenerator.php |
| the PDF and QR code | app/UI/Api/Invoicer/Models/Generators/InvoicePdfGenerator.php |
| the credit note cap | app/UI/Api/Invoicer/Models/Managers/CreditNoteLedger.php |
| the save hooks | app/UI/Api/Invoicer/Models/Managers/InvoiceManager.php |
| the cron tasks | app/UI/Cron/Invoicer/Presenters/ |
| the administration | app/UI/Admin/Invoicer/ |
Follow-up chapters: Manager lifecycle · Hydrators · E-shop · Scheduled tasks · Invoicing in the administration