InvoiceComposer
in package
General invoice builder for a foreign module.
Invoicer could build an invoice only from its own inputs: InvoiceGenerator from a
payment (single seller, taken from 7/invoice.default.sellerId) and the eshop
OrderInvoiceGenerator from an order. A module that is
neither (Company: closing a work sheet) had nothing to call without pulling in the eshop
library. Input is an InvoiceComposerRequest, output a filled Invoice object graph.
Contract (money is involved):
- Pure builder. No
persist/flush, no transaction, no lock, no write anywhere. The returned invoice is not managed by Doctrine; the caller saves it through InvoiceManager::save(). Only the save assigns the number from the billing line (InvoiceNumberGenerator,SELECT ... FOR UPDATEon the line), recalculates totals (InvoiceCalculator) and builds PDF and QR after the commit (completedSave). The composer touches none of that. - No idempotency. Two
compose()calls give two different unsaved invoices, and saving both burns two numbers from a live billing line. "Did I already issue this invoice?" belongs to the caller's domain (Company:state=closedandinvoice_id IS NOT NULLunderGET_LOCK('company_work_list_{id}')); Invoicer cannot detect a duplicate of a foreign domain. Not the same as the eshop idempotency keyed by(orderId, type). - Guards are hard, not silent. A missing seller, type, currency or lines gives
Result::failure(), never a half-empty invoice. OrderInvoiceGenerator::applySeller() just returns for an unknown seller (the order has its own snapshot as a rescue); here that would be an invoice with an empty seller header sent to the customer, worse than an error. - Derived numbers are not accepted. The DTO carries only primary inputs;
totalPrice,priceWithVat,vatInMoneyandrounding*are computed by the calculator inbeforeSave. - The snapshot wins over the directory. Even for a directory customer the fields are copied
to the invoice;
customerIdandcustomerContactIdstay references only, because an invoice may be regenerated years later while the directory address changes.
Not supported on purpose: shipping, payment method, vouchers, gifts and credit notes. Those
belong to the eshop and payments, which have their own generators, and every extra collection
is another place where a total can break. Invoice.localizationId is not set because the
write goes through a Doctrine association and a generator must not inject the entity manager;
the invoice formats amounts by currency.
The eshop does not switch to the composer: OrderInvoiceGenerator has its own axes (seller
snapshot on the order, rounding line, parity with the order) that a merge would have to carry
over completely.
Table of Contents
Constants
- DEFAULT_DUE_PERIOD : int = 14
- DEFAULT_LANGUAGE_ID : int = 1
- DEFAULT_UNIT : string = 'ks'
- MAX_PRICE : string = '9999999.999'
- Magnitude of a `decimal(10,3)` money column, see {@see validateLines()}.
- MAX_QUANTITY : string = '999999.99'
- Ceiling of the `decimal(8,2)` column; above it the INSERT fails in the strict SQL mode (a non-strict server would clamp the value).
- MONEY_SCALE : int = 3
- Scale of the Invoicer money columns (`decimal(10,3)`).
- SETTING_MODULE : int = 7
- Invoicer settings module (`SettingPresenter::MODULE`); source of fallbacks for omitted inputs.
Properties
- $customerManager : CustomerManager
- $invoiceTaxManager : InvoiceTaxManager
- $invoiceTypeManager : InvoiceTypeManager
- $paymentRegionManager : PaymentRegionManager
- $sellerManager : SellerManager
- $settingContext : SettingContext
Methods
- __construct() : mixed
- compose() : Result
- Builds an unsaved invoice from the request of a foreign module.
- appendLines() : void
- Input lines to `InvoiceItem`. Only the primary calculator inputs are set; derived fields stay empty because `InvoiceCalculator` overwrites them in `beforeSave`.
- applyClassification() : void
- Classification axes of the invoice. `productType x paymentRegion x userType` is the matrix from which {@see InvoicePdfGenerator} picks the PDF variant; the region arrives already verified by {@see resolvePaymentRegionId()} and is only written here.
- applyCustomer() : Result|null
- Customer: either a directory snapshot ({@see InvoiceComposerCustomer::fromDirectory()}) or the fields passed by the caller ({@see InvoiceComposerCustomer::fromSnapshot()}).
- applyDates() : void
- The issue date also drives the number mask (`%Y`/`%m`/`%d`) and the sequence `period_key` ({@see InvoiceNumberGenerator}), so a backdated invoice gets a number of its own period, not today's. The time is cut to midnight so two invoices of one day carry the same date.
- applyEmail() : void
- Flags the invoice for sending by e-mail. The composer sends nothing, it only records the intent and the recipient so the sender (cron or manager of the caller) need not ask again.
- applySeller() : void
- Seller from the directory; mirrors {@see InvoiceGenerator::applySeller()} and {@see \App\UI\Api\Eshop\Models\Generators\OrderInvoiceGenerator::applySeller()} so that invoices from different modules look the same. Two deliberate differences: - the seller is looked up by `sellerId` from the request, not from the settings (the whole point of multi-seller), - contact and bank account can be chosen explicitly; `7/invoice.default.sellerContactId` and `7/invoice.default.sellerBankAccountId` are only a fallback. A global setting may point to a contact of another seller; `Seller::getContact()` searches only its own collection, so a foreign id falls back to the seller's first contact and addresses never get mixed.
- applyTax() : void
- Applies VAT of the whole invoice through the unified tax engine.
- buildDirectorySnapshot() : array<string, string|int|bool|null>|null
- Directory to snapshot fields; `null` means the customer is not in the directory.
- countryCodeOrNull() : string|null
- ISO2 country code in upper case; anything else (including a country name) gives `null`.
- discountPercents() : string|null
- Line discount in percent for the `decimal(5,2)` column; `null` means no discount.
- findSeller() : Seller|null
- Seller including contacts and bank accounts; without them the invoice header would be half empty.
- intOrNull() : int|null
- money() : string
- Money value for a `decimal(10,3)` column (no thousands separators).
- numericOrNull() : string|null
- Numeric input of any shape to a numeric string, or `null`. A comma as decimal separator passes (form input arrives in Czech format); same normalization as {@see \App\Core\Traits\Shared\Models\Entities\DecimalQuantityTrait}.
- paymentRegionLanguageId() : int|null
- Default invoice language of the payment region (`null` when the region has none).
- resolveBillingLineId() : int|null
- Billing line: request, then the own line of the invoice type, then `7/invoice.default.billingLineId`. Same order as {@see InvoiceGenerator::resolveBillingLineId()} so an invoice of one type lands in the same line no matter who issues it.
- resolvePaymentRegionId() : int|null
- Payment region: request, then `7/invoice.default.paymentRegionId`, then the system default region. `null` means no source gave one, which `compose()` turns into a hard error.
- resolveUseVatOverride() : bool|null
- Returns the deprecated `useVat` override of the request, or `null` when it does not apply.
- settingInt() : int
- settingIntOrNull() : int|null
- settingStr() : string
- stringOrNull() : string|null
- validateDiscount() : string|null
- The line discount must be a number in 0-100 %. A negative discount would raise the price, above 100 % it would give a negative line; both are caller mistakes, not legitimate invoices. A given but non-numeric discount is an error too: dropping it silently would issue an invoice without the discount the caller asked for.
- validateLines() : string|null
- Validates the lines before the graph is built; returns the description of the first defect, or `null`.
Constants
DEFAULT_DUE_PERIOD
private
int
DEFAULT_DUE_PERIOD
= 14
DEFAULT_LANGUAGE_ID
private
int
DEFAULT_LANGUAGE_ID
= 1
DEFAULT_UNIT
private
string
DEFAULT_UNIT
= 'ks'
MAX_PRICE
Magnitude of a `decimal(10,3)` money column, see {@see validateLines()}.
private
string
MAX_PRICE
= '9999999.999'
MAX_QUANTITY
Ceiling of the `decimal(8,2)` column; above it the INSERT fails in the strict SQL mode (a non-strict server would clamp the value).
private
string
MAX_QUANTITY
= '999999.99'
MONEY_SCALE
Scale of the Invoicer money columns (`decimal(10,3)`).
private
int
MONEY_SCALE
= 3
SETTING_MODULE
Invoicer settings module (`SettingPresenter::MODULE`); source of fallbacks for omitted inputs.
private
int
SETTING_MODULE
= 7
Properties
$customerManager read-only
private
CustomerManager
$customerManager
$invoiceTaxManager read-only
private
InvoiceTaxManager
$invoiceTaxManager
$invoiceTypeManager read-only
private
InvoiceTypeManager
$invoiceTypeManager
$paymentRegionManager read-only
private
PaymentRegionManager
$paymentRegionManager
$sellerManager read-only
private
SellerManager
$sellerManager
$settingContext read-only
private
SettingContext
$settingContext
Methods
__construct()
public
__construct(SettingContext $settingContext, SellerManager $sellerManager, CustomerManager $customerManager, InvoiceTypeManager $invoiceTypeManager, PaymentRegionManager $paymentRegionManager, InvoiceTaxManager $invoiceTaxManager) : mixed
Parameters
- $settingContext : SettingContext
- $sellerManager : SellerManager
- $customerManager : CustomerManager
- $invoiceTypeManager : InvoiceTypeManager
- $paymentRegionManager : PaymentRegionManager
- $invoiceTaxManager : InvoiceTaxManager
compose()
Builds an unsaved invoice from the request of a foreign module.
public
compose(InvoiceComposerRequest $request) : Result
Success: a Result with the Invoice entity in its data. Failure:
Result::failure() with a message and ['errorCode' => ...] in the data. The codes are
permanent: they are defects of the input or configuration that a retry does not fix, so
the caller must not report them as "the cron will catch up":
sellerMissing:sellerId<= 0 or the seller is not in the directory,currencyMissing:currencyIdmissing,invoiceTypeMissing: invoice type missing or unknown,numberSourceMissing: neither a billing line nor a manual number,numberSourceConflict: both are set (mutually exclusive, enforced byInvoiceManager::beforeSave()),noLines: empty list of lines,invalidLine: a bad line: no name, a non-numeric, non-positive or out-of-range quantity, a non-numeric or out-of-range price, a VAT rate or discount outside 0-100 %,customerMissing:fromDirectory()for a customer that is not in the directory; an intentional invoice without a customer is sent ascustomer: null, not as an unknown id,paymentRegionMissing: the payment region could not be determined; without it the PDF variant matrix finds no variant and the invoice would have no PDF,sellerVatModeMissing: the seller has no tax status on the tax point of the invoice (the status history starts later); a silent default would issue the invoice in the wrong VAT mode.
Parameters
- $request : InvoiceComposerRequest
Return values
ResultappendLines()
Input lines to `InvoiceItem`. Only the primary calculator inputs are set; derived fields stay empty because `InvoiceCalculator` overwrites them in `beforeSave`.
private
appendLines(Invoice $invoice, InvoiceComposerRequest $request) : void
The line useVat is true unless the caller turns it off explicitly. For a non-payer the
header silences it anyway (calculator: useVat && line.useVat), so the per-line opt-out
only matters for a mixed mode of a VAT payer.
Parameters
- $invoice : Invoice
- $request : InvoiceComposerRequest
applyClassification()
Classification axes of the invoice. `productType x paymentRegion x userType` is the matrix from which {@see InvoicePdfGenerator} picks the PDF variant; the region arrives already verified by {@see resolvePaymentRegionId()} and is only written here.
private
applyClassification(Invoice $invoice, InvoiceComposerRequest $request, int|null $billingLineId, string|null $manualNumber, int $paymentRegionId) : void
Parameters
- $invoice : Invoice
- $request : InvoiceComposerRequest
- $billingLineId : int|null
- $manualNumber : string|null
- $paymentRegionId : int
applyCustomer()
Customer: either a directory snapshot ({@see InvoiceComposerCustomer::fromDirectory()}) or the fields passed by the caller ({@see InvoiceComposerCustomer::fromSnapshot()}).
private
applyCustomer(Invoice $invoice, InvoiceComposerRequest $request) : Result|null
The directory mapping copies the admin picker
(CustomerPickerTrait::customerPrefillData()) so that an invoice issued by a foreign
module looks exactly like one created by hand in the form: name is the customer name (not
the contact's) and name2 the additional person or branch from the contact. An empty
customer name falls back to the contact name; an invoice header without a customer name is
unusable.
userTypeId from the contact fills the PDF variant axis only when neither the request nor
the settings decided it (an explicit choice of the caller beats the directory).
A customer not found for fromDirectory() is a hard error: returns Result::failure(),
null means everything is fine.
Parameters
- $invoice : Invoice
- $request : InvoiceComposerRequest
Return values
Result|nullapplyDates()
The issue date also drives the number mask (`%Y`/`%m`/`%d`) and the sequence `period_key` ({@see InvoiceNumberGenerator}), so a backdated invoice gets a number of its own period, not today's. The time is cut to midnight so two invoices of one day carry the same date.
private
applyDates(Invoice $invoice, InvoiceComposerRequest $request) : void
Parameters
- $invoice : Invoice
- $request : InvoiceComposerRequest
applyEmail()
Flags the invoice for sending by e-mail. The composer sends nothing, it only records the intent and the recipient so the sender (cron or manager of the caller) need not ask again.
private
applyEmail(Invoice $invoice, InvoiceComposerRequest $request) : void
Parameters
- $invoice : Invoice
- $request : InvoiceComposerRequest
applySeller()
Seller from the directory; mirrors {@see InvoiceGenerator::applySeller()} and {@see \App\UI\Api\Eshop\Models\Generators\OrderInvoiceGenerator::applySeller()} so that invoices from different modules look the same. Two deliberate differences: - the seller is looked up by `sellerId` from the request, not from the settings (the whole point of multi-seller), - contact and bank account can be chosen explicitly; `7/invoice.default.sellerContactId` and `7/invoice.default.sellerBankAccountId` are only a fallback. A global setting may point to a contact of another seller; `Seller::getContact()` searches only its own collection, so a foreign id falls back to the seller's first contact and addresses never get mixed.
private
applySeller(Invoice $invoice, Seller $seller, InvoiceComposerRequest $request, bool $sellerUseVat) : void
useVat is the VAT payer status of the seller on the tax point of the invoice
(SellerManager::resolveUseVatOn(), resolved in compose()) unless the request
overrides it explicitly without a taxOverride; the override is applied after the seller is loaded.
Parameters
- $invoice : Invoice
- $seller : Seller
- $request : InvoiceComposerRequest
- $sellerUseVat : bool
applyTax()
Applies VAT of the whole invoice through the unified tax engine.
private
applyTax(Invoice $invoice, InvoiceComposerRequest $request, Seller $seller, DateTime $taxDate) : void
The composer decides nothing about tax: it hands InvoiceTaxManager the lines (supply
kind), the customer context from the invoice snapshot (ISO country, VAT and registration
numbers), the tax point and an optional manual override. The manager writes the line rates,
the header use_vat and the tax_* snapshot (treatment code, clause, flags, summary-report
code), from which the PDF then picks a variant by the matrix.
A request without a supply kind (on the line and in the header) and without a manual
override does not call the engine at all: the invoice behaves as before (line rates or
7/invoice.default.vatPercents, VAT payer status from the seller tax status) and no
snapshot is left. No path may fail here: an engine without a usable answer (missing rate or
seller status) returns false and the invoice goes out in the former mode.
$request->useVat is the last word of the deprecated override without justification: it is
applied after the engine so existing callers get what they got before, but only without
taxOverride (resolveUseVatOverride()). The new path is taxOverride, which also
changes the treatment code and clause and carries a reason.
Parameters
- $invoice : Invoice
- $request : InvoiceComposerRequest
- $seller : Seller
- $taxDate : DateTime
buildDirectorySnapshot()
Directory to snapshot fields; `null` means the customer is not in the directory.
private
buildDirectorySnapshot(InvoiceComposerCustomer $customer) : array<string, string|int|bool|null>|null
A customer without a contact is not an error (the directory allows it): at least the name
is filled, the address fields stay empty and the invoice shows it. A missing customer is an
error: the caller explicitly asked to take it from the directory, and empty fields cannot
be told apart from an intentional customer: null or empty snapshot, so the invoice would
silently go out without a customer. A non-positive customerId falls here too:
fromDirectory(0) is a caller mistake.
Parameters
- $customer : InvoiceComposerCustomer
Return values
array<string, string|int|bool|null>|nullcountryCodeOrNull()
ISO2 country code in upper case; anything else (including a country name) gives `null`.
private
countryCodeOrNull(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nulldiscountPercents()
Line discount in percent for the `decimal(5,2)` column; `null` means no discount.
private
discountPercents(InvoiceComposerLine $line) : string|null
Format only. The range is guarded by validateDiscount(): clamping (min/max)
would turn a 150 % discount into 100 % and the invoice would go out with a different
amount without the caller learning anything. Zero means no discount (the calculator would
only add 0 anyway).
Parameters
- $line : InvoiceComposerLine
Return values
string|nullfindSeller()
Seller including contacts and bank accounts; without them the invoice header would be half empty.
private
findSeller(int $sellerId) : Seller|null
Parameters
- $sellerId : int
Return values
Seller|nullintOrNull()
private
intOrNull(mixed $value) : int|null
Parameters
- $value : mixed
Return values
int|nullmoney()
Money value for a `decimal(10,3)` column (no thousands separators).
private
money(int|float|string $value) : string
Parameters
- $value : int|float|string
Return values
stringnumericOrNull()
Numeric input of any shape to a numeric string, or `null`. A comma as decimal separator passes (form input arrives in Czech format); same normalization as {@see \App\Core\Traits\Shared\Models\Entities\DecimalQuantityTrait}.
private
numericOrNull(int|float|string|null $value) : string|null
Parameters
- $value : int|float|string|null
Return values
string|nullpaymentRegionLanguageId()
Default invoice language of the payment region (`null` when the region has none).
private
paymentRegionLanguageId(int $paymentRegionId) : int|null
Parameters
- $paymentRegionId : int
Return values
int|nullresolveBillingLineId()
Billing line: request, then the own line of the invoice type, then `7/invoice.default.billingLineId`. Same order as {@see InvoiceGenerator::resolveBillingLineId()} so an invoice of one type lands in the same line no matter who issues it.
private
resolveBillingLineId(InvoiceComposerRequest $request, InvoiceType $invoiceType) : int|null
Parameters
- $request : InvoiceComposerRequest
- $invoiceType : InvoiceType
Return values
int|nullresolvePaymentRegionId()
Payment region: request, then `7/invoice.default.paymentRegionId`, then the system default region. `null` means no source gave one, which `compose()` turns into a hard error.
private
resolvePaymentRegionId(InvoiceComposerRequest $request) : int|null
A non-positive value counts as "not given" and the search continues (neither a 0 from the
caller nor getDefaultPaymentRegionId(), which returns 0 for a system without an active
region, is a valid id). Sources are walked lazily: while a higher priority applies, the
default region is not queried at all.
Parameters
- $request : InvoiceComposerRequest
Return values
int|nullresolveUseVatOverride()
Returns the deprecated `useVat` override of the request, or `null` when it does not apply.
private
static resolveUseVatOverride(InvoiceComposerRequest $request) : bool|null
IMPORTANT: with taxOverride set, useVat is ignored, as InvoiceComposerRequest
promises. The manual override sets the header use_vat together with the line rates and the
tax_* snapshot; letting the flag win afterwards would give, for example, a payer clause
with VAT rates on an invoice marked as without VAT.
Parameters
- $request : InvoiceComposerRequest
Return values
bool|nullsettingInt()
private
settingInt(string $key, int $default) : int
Parameters
- $key : string
- $default : int
Return values
intsettingIntOrNull()
private
settingIntOrNull(string $key) : int|null
Parameters
- $key : string
Return values
int|nullsettingStr()
private
settingStr(string $key, string $default) : string
Parameters
- $key : string
- $default : string
Return values
stringstringOrNull()
private
stringOrNull(mixed $value) : string|null
Parameters
- $value : mixed
Return values
string|nullvalidateDiscount()
The line discount must be a number in 0-100 %. A negative discount would raise the price, above 100 % it would give a negative line; both are caller mistakes, not legitimate invoices. A given but non-numeric discount is an error too: dropping it silently would issue an invoice without the discount the caller asked for.
private
validateDiscount(InvoiceComposerLine $line, int $index) : string|null
Parameters
- $line : InvoiceComposerLine
- $index : int
Return values
string|nullvalidateLines()
Validates the lines before the graph is built; returns the description of the first defect, or `null`.
private
validateLines(array<int, InvoiceComposerLine> $lines) : string|null
The quantity must be positive and within the decimal(8,2) ceiling: above 999999.99
the save fails on an out-of-range error in the strict SQL mode (after the number lock was
taken), and a non-strict server would clamp it so the entity claimed a different number
than the DB. Zero or negative is always a caller
mistake; a credit note uses a negative price, not a negative quantity.
Two checks that guard against silent behavior:
- price range:
decimal(10,3)holds +-9999999.999; above it the strict SQL mode rejects the save and a non-strict server would clamp the amount, - VAT rate and discount outside 0-100 %: a discount used to be clamped silently (see discountPercents()), so the caller never learned about the mistake and the invoice went out with a different amount than requested.
Parameters
- $lines : array<int, InvoiceComposerLine>