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.
Lock of an issued document#
A document the customer has seen gets locked, irreversibly. The state lives in the columns
locked_datetime and lock_reason; both are in defaultSkipFields, so a normal object-graph
save does not overwrite them. There are three ways to lock:
| When | Through | lock_reason |
|---|---|---|
writing emailIsSent = true |
InvoiceManager::updateColumns(), the lock goes into the same UPDATE as the flag |
email (Invoice::LOCK_REASON_EMAIL) |
| saving a credit note or cancellation | afterSave() of the credit note locks the source (relatedInvoiceId) in the same transaction; a failed lock rolls the credit note back too |
creditNote |
| manual action in the administration | InvoiceManager::lock(), Api invoicer/invoice/lock |
manual |
Locking is a conditional UPDATE … WHERE locked_datetime IS NULL: the first lock wins and an
already locked document keeps its original time and reason.
A full save of a locked document is rejected by InvoiceManager::beforeSave() with
INVOICE_LOCKED (errorCode invoiceLocked) on every path: administration, Api and
generators. It reads the state from the database (lockedAt(), raw DBAL), not from the entity,
because lockedDatetime is missing from the payload. What a user may still change on a locked
document (payment status, note, e-mail details) is written by
InvoiceFormTrait::saveLockedInvoice() directly through updateColumns(), which skips the
hooks: nothing is recalculated and the PDF stays as it was. What the user sees is described in
Invoices in the administration.
Procedure: changing an issued document from code
- Get the state with
InvoiceManager::lockedAt($id). It reads straight from the database (raw DBAL) without hydration, so it can be called even in the middle of hooks. - On a locked document write only columns outside the amounts, lines and customer, and do it
through
updateColumns(). - Fix anything else with a credit note or a cancellation.
Write the sent flag only through InvoiceManager::updateColumns()
Only this method adds the lock to emailIsSent = true. When a full save or SQL outside the
manager sets the flag, the document is "sent" but unlocked and can still be changed, even
though the customer already has it.
A numbered document is not deleted
beforeDelete() rejects it with INVOICE_NUMBERED_DELETE_BLOCKED. The billing line assigns
MAX(order) + 1, so deleting the last document would hand its number to the next one (two
different invoices under one number at the customer and in the books) and deleting any other
would leave a gap in the line. Only a document without a number can be deleted, such as an
unfinished import. A bulk deleteAll() skips the hooks, so do not call it on documents.
Customer contact name#
A customer contact (CustomerContact) has a structured triple companyName, firstname,
lastname and next to it the display fields name and name2, which are copied onto
documents. They are derived in one place, CustomerManager::beforeSave(), and only into empty
fields:
| Filled triple | name |
name2 |
|---|---|---|
| company and person | company name | first name and surname |
| company only | company name | unchanged |
| person only | first name and surname | unchanged |
| nothing (old contact) | unchanged | unchanged |
The hook covers every write, because contacts are saved only as the customer's owned
collection through CustomerManager (the administration and the e-shop's OrderCustomerManager);
CustomerContactManager is used only for reading.
Changing the company name does not rewrite the contact name
Once filled, name and name2 are never overwritten or cleared by the hook, so a manual
edit is not lost. When companyName or the surname changes on the contact, new documents
still carry the old name. To have it derived again, empty name / name2 in the same save.
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.
A document from another module#
A module that is neither a payment nor an order (for example My company when it
invoices work sheets) has the document built by InvoiceComposer::compose(). The input is an
InvoiceComposerRequest, the output a Result with an unsaved invoice in its data.
| Aspect | How it works |
|---|---|
| writes | none: the composer does not persist, takes no lock and no number; number, recalculation and PDF only happen in InvoiceManager::save() |
| idempotency | none: two calls are two invoices and saving both burns two numbers; the caller guards duplicates in its own domain under its own lock |
| seller | by the sellerId parameter; contact and account optional, otherwise 7/invoice.default.*, then the first one in the seller's collection (a foreign contact id never mixes in another seller's address) |
| customer | InvoiceComposerCustomer::fromDirectory($customerId, $contactId) from the directory, or fromSnapshot($fields); the fields are always copied to the document, customerId is only a reference |
| lines | InvoiceComposerLine::create(...) with primary inputs only: name, quantity, unit price without VAT, rate, discount, supply kind |
| VAT | payer status from SellerManager::resolveUseVatOn() on the tax point; rates, treatment code and clause are written by InvoiceTaxManager when a line has a supply kind or taxOverride is set |
| not supported | shipping, payment method, vouchers, gifts, credit notes, localizationId (amounts are formatted by currency) |
The DTOs have a private constructor and are built only by their factories (create(),
fromDirectory(), fromSnapshot()). A class that cannot be instantiated drops out of DI search
by itself, so the Dto/ folder needs no exception in the config.
Errors are permanent (a defect of the input or the settings, a retry does not help). The code
is in the result data under the errorCode key:
errorCode |
When |
|---|---|
sellerMissing |
sellerId ≤ 0 or the seller is not in the directory |
currencyMissing |
currencyId ≤ 0 |
invoiceTypeMissing |
the document type is missing or does not exist |
numberSourceMissing |
no series (request, document type, 7/invoice.default.billingLineId) and no manual number |
numberSourceConflict |
a manual number together with a positive billingLineId (0 reads as "no series") |
noLines |
an empty list of lines |
invalidLine |
a line without a name; quantity non-numeric, ≤ 0 or above 999999.99; price non-numeric or outside ±9999999.999; rate or discount outside 0–100 % |
customerMissing |
fromDirectory() for a customer that is not in the directory (including customerId ≤ 0) |
paymentRegionMissing |
neither the request, 7/invoice.default.paymentRegionId nor the system default region gives a region |
sellerVatModeMissing |
the seller has no tax status on the tax point (the history starts later) |
Procedure: issuing a document from another module
- Under your own lock, check that no document exists for this record yet.
- Build the lines with
InvoiceComposerLine::create()and named arguments; the price is per unit and without VAT, the calculator multiplies it by the quantity. - Pass the customer through
fromDirectory(), or throughfromSnapshot()when it is not in the directory or the document must carry data valid at another moment. - Call
compose(). On failure return theerrorCodeand save nothing. - Save the invoice through
InvoiceManager::save()and checkisSuccess(). - Store the invoice id on your side. Do not use
Invoice.orderIdfor that, it already mixes ids of e-shop orders and old payments.
An unknown customer snapshot key is silently dropped
fromSnapshot() takes the fields without the customer prefix (name, street,
countryCode, vatId, userTypeId, …; delivery dlv* only with useDelivery: true). A typo
in a key does not fail, the value is just missing on the document. The tax engine reads the
customer's country from countryCode (ISO2), not from the name in country. Without it the
engine does not know the customer's country and decides the VAT treatment without it.
useVat and vatPercents in the request are fallback inputs
A manual VAT treatment belongs in taxOverride (InvoiceTaxOverride::create(): code, rate,
mandatory reason). It returns null for invalid input; the caller must return that as a
validation error, otherwise the document goes out in a different mode than requested.
useVat is applied after the engine, but only without taxOverride; with a manual
override it is ignored and the override decides the header use_vat
(InvoiceComposer::resolveUseVatOverride()).
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.
Add a new series reference without a foreign key to the delete check
BillingLineManager::beforeDelete() refuses to delete a series referenced by documents,
templates, document types or companies (invoicer_billing_line_id). Document types and
companies have no foreign key, so the check runs raw DBAL over the list of tables in that
method. A table that references a series and is missing from the list lets the series be
deleted, and issuing a document then fails with "Billing line not found".
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
A VAT rate is a string, not a number
A rate need not be a whole number (5.5 %, 2.1 %, 13.5 %), so in PHP it travels as a
numeric-string ('5.50'), it is DECIMAL(5,2) in the database and is calculated with
bcmath. Input parsing (comma or full stop, 0–100), the group key and display are held by
one class, App\Core\Utils\Helpers\VatRate: normalize() → '5.50', key() → '5.5'
(the identity of a group — rates 5 and 5.5 must not merge; never $byRate[$float],
PHP truncates a float key to int), format() / formatFor() → "5,5" / "5.5" by locale.
In Latte there is the {$rate|vatRate} filter. Forms take a rate through
Form::addVatRate(). The VAT recap on the PDF keeps rateKey (the canonical key) and
rate (the text for the template) per group.
The line quantity is a string too
InvoiceItem and InvoiceTemplateItem have quantity as DECIMAL(8,2) (hours, metres,
kilograms) through DecimalQuantityTrait. The getter returns a numeric string in the fixed
form '2.50'; the setter takes int|float|string, a comma and thousand spaces
(Decimal::clean()), rounds half-up to 2 places and turns non-numeric input into '0.00'.
Whoever computes with the quantity casts it themselves ((float) $line->getQuantity()):
abs() or intdiv() on a string fail with a TypeError under strict_types=1. The setter
does not clip the range (max 999999.99); InvoiceComposer checks it. Shipping and payment
method lines keep an integer quantity. For display Latte has the {$qty|quantity} filter: a
whole number without decimals ("5" from '5.000'), otherwise without trailing zeros ("1.5",
"0.25") with the separator of the localization (the document localization wins over the
request, as with vatRate); |quantity:true gives the machine form with a full stop for
value and data attributes. In PHP the same is Quantity::format(); invoicing forms and
the PDF go through the document wrapper QuantityFormat::quantity()
(InvoicePdfGenerator::formatQuantity()), which keeps a missing line as null.
The document form is stricter than the quantity setter
The quantity field in the administration (invoice and template) is guarded by the
QUANTITY_PATTERN mask: an optional minus, 1–6 integer digits, 0–2 decimals after a comma
or a dot. "1 000" with a thousand space does not pass and the admin gets a message at the
field. Without the mask such input would silently end up as a different quantity than the
admin typed. The mask accepts a minus because of negative quantities from old documents and
imports; otherwise such a document could not be saved again. Credit notes do not produce
negative quantities: they mirror the positive quantity and flip the sign of the price
(mirrorLine()).
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 quantity ≤ invoiced quantity
| 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 manual credit note for an issued document from the administration
(InvoiceCreditNoteFormTrait) loads its basis through api/invoicer/invoice/credit-note-preview
and issues it through credit-note (InvoiceCreditNoteManager). The cap is checked only by
issue() under the document lock cms_invoicer_invoice_{id}; the max on the quantity in the
form is just a convenience. A fixed discount is not selected in the form; for a partial credit
note it is dissolved proportionally on its own.
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.
Ledger quantities are strings with 3 decimals
Invoice items have quantity as decimal(10,3) (the same scale as the stock), shipping and
payment lines whole pieces (int). CreditNoteLedger returns invoiced, credited and
remaining as numeric strings ('1.500') and sums and compares them with bcmath, never with
float or (int) — a cut would undercount the cap, and a float does not add 0.5 + 0.7 to
exactly 1.2. Convert caller input with CreditNoteLedger::normalizeQuantity() (decimal comma
or dot, half-up rounding to 3 places like prices). violations() rejects a fractional
quantity on a shipping or payment line. On a partial credit note a fixed discount dissolves in
proportion to the quantity, rounded half-up and never above its remainder.
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.
The PDF template variant is chosen by the document type (InvoiceType) from one of two
matrices (InvoicePdfGenerator::resolveVariant()); a variant already stored on the document
(pdfTemplateVariantId) always wins:
| Document | Matrix | Key |
|---|---|---|
has tax_treatment (VAT treatment code) |
pdfVariantTreatmentRels |
the treatment code, otherwise the "Default (*)" row |
| does not have it (older documents) | pdfTemplateVariantRels |
kind of supply × payment region × customer type × seller mode (the document's useVat, 1 payer / 0 non-payer) |
Both matrices are edited by the document type form (InvoiceTypeFormTrait) and saved by
replacing the whole collection in one save. The older matrix is rendered twice, on a payer
tab and a non-payer tab (PdfVariantMatrixTrait::pdfVariantSellerModes()).
Both halves of the matrix must always be rendered in the form
Nette evaluates an unrendered field as empty on submit and the collection replace deletes that half of the matrix. Whoever makes a tab conditional or lazy-loaded loses all the non-payer (or payer) variants on the first save.
Without a PDF variant no file is produced at all, and nobody is told
When nothing in the matrix matches, the invoice saves fine, just without a file. The
generator writes only a line to the pdf log; neither the administration nor the Api
reports an error. 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.
PDF template: variant, layout and Latte#
The file is put together by the shared App\UI\Api\System\Models\Generators\PdfGenerator
(mPDF) from two parts. The variant (PdfTemplateVariant) carries the meaning and the name
(meta_title from variant_texts), the layout (PdfTemplateLayout) the look: css and,
per language, header, html and footer (PdfTemplateVariantLayoutText). The document css is
the layout css + the optional variant css.
| Step | What is taken |
|---|---|
| layout | 7/invoice.pdf.layoutId, otherwise the default layout (isDefault) |
| content | the variant text for the chosen layout and the document language, otherwise the first text of the same layout; when the chosen layout has none at all, the default layout (logged to pdf) |
| render | a standalone Latte engine (StringLoader) with PriceLatteExtension and the date filters; {#BODY#} in the email.latte wrapper is replaced by the content |
In the header and footer, mPDF tags are written as #.PAGENO.# and #.nbpg.#. Latte would take
{PAGENO} for its own tag; after rendering the generator turns #. and .# back into { and
}. The default layout is switched only with the toggle in the isDefault column of the layout grid
(:Admin:System:PdfTemplateLayout); PdfTemplateLayoutManager::updateColumns() switches all the others off when one is
switched on.
The only default layout cannot be switched off or deleted
Without a default layout resolveContent() would return null even when the setting names a
layout, and every document would be stored without a PDF. PdfTemplateLayoutManager
therefore refuses to switch off or delete the last default layout with the
PDF_LAYOUT_DEFAULT_REQUIRED code and the grid shows the reason. It does not pick another
layout by itself, so the look of documents never changes without the admin knowing. Change
the default by switching another layout on.
The template is Latte without a sandbox
Layout and variant content runs as Latte code that may call any PHP function. Whoever may edit PDF templates must be as trusted as a developer; do not grant it to an ordinary role.
Filters from DI extensions are not in the template
The engine is standalone, not from LatteFactory. Register a new PDF filter in
PdfGenerator::createEngine(), otherwise rendering fails and the document stays without a
PDF. Format amounts with the PriceLatteExtension filters: only it knows the document
localization and accounting mode.
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/invoice.payment.auto |
Both work in batches (7/schedule.batchSize, 7/payment.batchSize, 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. The invoice and the date shift share one transaction (a crash between them cannot leave an issued invoice with a schedule that did not move), and the e-mail goes out only after the commit.
The order inside the transaction (ScheduleManager::issueAtomically()):
- Claiming the period (
claimPeriod()): a conditionalUPDATEof the schedule whoseWHEREholdsactive = 1, the loadedrenewal_dateand every column the cron built the invoice from (period, interval, end, source, template, invoice). The row stays locked until commit. A one-off schedule (periodneveror interval ≤ 0) is deactivated instead of advanced. - Saving the invoice through
InvoiceManager::save(). The transaction was opened directly on the DBAL connection (inAtomicTransaction()), soTransactionManageropens the save as a savepoint under it. A rollback undoes everything including the number from the billing line, so numbering gets neither a gap nor a duplicate.
Zero changed rows in step 1 means a concurrent run advanced the period or the admin changed
the schedule meanwhile. The schedule is skipped with INVALID_STATE and no backoff, and the
next run takes it in its new form, so an invoice never comes out of the old configuration.
Other failures (missing source or billing line, the invoice did not save, an exception) get a
growing backoff in the queue invoicer.schedule.generate;
documents from payments use the queue invoicer.payment.invoice.
The PDF is created still inside the schedule transaction
The invoice's completedSave() runs inside the outer transaction, because a nested commit
writes nothing. If only the final commit fails, a PDF without an invoice row stays on disk.
Nothing points to it, so it is harmless; that is why the schedule advance goes first in the
transaction.
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.
A document from a payment may have a "Haléřové vyrovnání" (rounding) line
The payment is rounded to the currency precision, document lines are 3-decimal price list
bases and the header is rounded to 3 places. InvoiceGenerator bills the difference as a
Haléřové vyrovnání line with rate 0, so total_price_with_vat matches the paid amount
exactly. When the difference exceeds 0.010 (PER_LINE_ROUNDING_LIMIT), the per-tariff
breakdown is dropped and the base is computed back from the paid amount (fallbackBase(),
possibly again with a rounding line). Removing the line puts the document a cent off the
payment.
Sending a document by e-mail#
Delivering the e-mail and writing emailIsSent to the invoice cannot be made atomic: the e-mail
is an external effect outside the transaction. All three places that send a document (the Api
InvoicePresenter for a manual send from the administration, InvoiceGenerator for documents
from payments, ScheduleManager for schedules) therefore go through
InvoiceEmailAudit::sendAndRecord(). It writes the audit only after a successful send and
returns one of three InvoiceEmailState values:
| State | What happened | How to report it |
|---|---|---|
NotSent |
sending or queueing failed, the audit was not written | an error, sending again is fine |
Sent |
sent and recorded | success |
SentNotRecorded |
sent, but the audit write failed; the invoice still shows emailIsSent = false |
neither success nor failure: "do not resend" |
For the third state the manual send returns error: 'sentButNotRecorded' with emailSent: true,
the schedule writes it to the run errors and the payment generator only to the
invoicer.sendEmail log.
Procedure: adding another place that sends a document
- Wrap the send in a closure that returns a
Result(e.g.EmailManager::sendEmailFromText()). - Put the audit write in a second closure (
InvoiceManager::updateColumns($id, ['emailIsSent' => true, …])). - Pass both to
InvoiceEmailAudit::sendAndRecord()together with a closure that writes to the log. - Report
SentNotRecordedseparately, neither as success nor as not sent.
A discarded Result of the audit write leads to a second e-mail
When updateColumns() is called after the send and its result is not checked, a failed
UPDATE leaves the e-mail out and the invoice as "not sent". The admin sees success and sends
the document again.
Document VAT and the seller's status#
Whether the seller charges VAT is decided by the seller's tax status history
(cms_mod_invoicer_seller_tax_statuses: status, valid from), not by the user type or a flat
flag. The admin edits it in the "Tax status" fieldset of the seller form together with the
country of establishment. The question "is a payer on a date" has a single place,
SellerManager::resolveUseVatOn():
| History | Result |
|---|---|
| the status on the date is payer | true |
| the status on the date is identified person or non-payer | false |
| the seller has no history row | the flat sellers.use_vat |
| the history starts after the date | null, the caller decides (the composer fails, the generators log and take the flat flag) |
The rate, treatment code and clause of a manually issued document (form, template, schedule,
InvoiceComposer) are determined by InvoiceTaxManager from the kind of supply of the lines
and the customer data, and written to the lines and the header (invoices.tax_*). A document
from a payment does not compute the treatment code, it copies it from the payment. A document
with no line that has a kind of supply and no manual override is computed the old way with the
rates the caller sent.
The flat sellers.use_vat is only a mirror as of the save day
The seller form overwrites it on every save with the status "payer as of today". A status
dated in the future gets into it only with the next save. Whoever reads the flag directly
instead of resolveUseVatOn() issues a document by the state on the day the seller was
last saved.
The admin may override the treatment on a document by hand (the "VAT" panel of the invoice
form). The override replaces the code and rate of all lines with a kind of supply, the
document gets the MANUAL_OVERRIDE flag and the reason in tax_override_reason. The code
DOMESTIC_REVERSE_CHARGE (domestic reverse charge) is never returned by the engine, it
arises only this way.
An override without a reason is not saved
The requirement hangs on the taxOverrideReason field (addConditionOn), not on the
form. Admin templates do not render whole-form errors, so a rule on the form would reject
the save with no visible message.
Document defaults in the settings#
The document defaults live in the settings of module 7 under the keys invoice.default.*
(seller, contact, account, series, kind of supply, user type, language, unit, due period, VAT
rates). For documents from orders, the e-shop (module 4) may override them with a key that has
the same suffix: 4/invoicer.<suffix> (invoicer.sellerId, billingLineId, userTypeId,
paymentRegionId). The chain is composed by OrderInvoiceGenerator (settingIntOrNull(),
settingInt(); the module 4 key is derived by eshopInvoicerKey()):
| Field of a document from an order | Order |
|---|---|
| seller and their contact and account | the seller snapshot in the order → 4/invoicer.* → 7/invoice.default.* |
| series | the document type's series (InvoiceType::getBillingLineId()) → 4 → 7 |
| payment region | the order's region → 4 → 7 → the default region of the localization |
| everything else | 4 → 7 |
For ids, a module 4 value that is not a positive number (empty string, 0) counts as missing. A
numeric setting (duePeriod) takes even a zero from module 4.
Procedure: adding a document default
- Add the key
invoice.default.<suffix>toTEXT_KEYSinapp/UI/Admin/Invoicer/Presenters/SettingPresenter.php. - Read it in the generator through
settingIntOrNull()orsettingInt()with the whole module 7 key. - If the e-shop should override it, add the field
invoicer.<suffix>to the e-shop settings (app/UI/Admin/Eshop/Presenters/SettingPresenter.php).
A key outside invoice.default.* has no e-shop override
eshopInvoicerKey() does not convert another prefix, so an invoicer.* field added to the
e-shop settings for such a key is never read.
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 |
| a document from another module | app/UI/Api/Invoicer/Models/Generators/InvoiceComposer.php, inputs in Generators/Dto/ |
| sending by e-mail | app/UI/Api/Invoicer/Models/Email/InvoiceEmailAudit.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/ |
| document VAT and the seller's status | app/UI/Api/Invoicer/Models/Managers/InvoiceTaxManager.php, SellerManager::resolveUseVatOn() |
| the defaults chain from the e-shop | app/UI/Api/Eshop/Models/Generators/OrderInvoiceGenerator.php |
| the administration | app/UI/Admin/Invoicer/ |
| locking and deleting a document | InvoiceManager::beforeSave(), updateColumns(), lock(), beforeDelete() |
| the customer contact name | app/UI/Api/Invoicer/Models/Managers/CustomerManager.php |
| the atomic schedule run | app/UI/Api/Invoicer/Models/Managers/ScheduleManager.php (issueAtomically(), claimPeriod()) |
Follow-up chapters: Manager lifecycle · Hydrators · E-shop · Scheduled tasks · Invoicing in the administration