Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / Invoicing

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

  1. 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.
  2. On a locked document write only columns outside the amounts, lines and customer, and do it through updateColumns().
  3. 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#

  1. Assemble an Invoice — the parties, the currency, the issue date and the lines.
  2. Do not fill the monetary fields. Whatever you put there, InvoiceCalculator overwrites in beforeSave(). The inputs are only the price, quantity, VAT rate, discount and the useVat / useDiscount switches.
  3. Set the number only when importing. Normally leave number empty and fill in billingLineId — the number is assigned by the series.
  4. Save through InvoiceManager::save(). The recalculation, the numbering and the PDF hook themselves in.
  5. 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

  1. Under your own lock, check that no document exists for this record yet.
  2. Build the lines with InvoiceComposerLine::create() and named arguments; the price is per unit and without VAT, the calculator multiplies it by the quantity.
  3. Pass the customer through fromDirectory(), or through fromSnapshot() when it is not in the directory or the document must carry data valid at another moment.
  4. Call compose(). On failure return the errorCode and save nothing.
  5. Save the invoice through InvoiceManager::save() and check isSuccess().
  6. Store the invoice id on your side. Do not use Invoice.orderId for 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()):

  1. Claiming the period (claimPeriod()): a conditional UPDATE of the schedule whose WHERE holds active = 1, the loaded renewal_date and every column the cron built the invoice from (period, interval, end, source, template, invoice). The row stays locked until commit. A one-off schedule (period never or interval ≤ 0) is deactivated instead of advanced.
  2. Saving the invoice through InvoiceManager::save(). The transaction was opened directly on the DBAL connection (inAtomicTransaction()), so TransactionManager opens 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

  1. Wrap the send in a closure that returns a Result (e.g. EmailManager::sendEmailFromText()).
  2. Put the audit write in a second closure (InvoiceManager::updateColumns($id, ['emailIsSent' => true, …])).
  3. Pass both to InvoiceEmailAudit::sendAndRecord() together with a closure that writes to the log.
  4. Report SentNotRecorded separately, 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

  1. Add the key invoice.default.<suffix> to TEXT_KEYS in app/UI/Admin/Invoicer/Presenters/SettingPresenter.php.
  2. Read it in the generator through settingIntOrNull() or settingInt() with the whole module 7 key.
  3. 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

Cookie settings