CoolCMS API

InvoiceComposer
in package

FinalYes

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):

  1. 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 UPDATE on the line), recalculates totals (InvoiceCalculator) and builds PDF and QR after the commit (completedSave). The composer touches none of that.
  2. 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=closed and invoice_id IS NOT NULL under GET_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).
  3. 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.
  4. Derived numbers are not accepted. The DTO carries only primary inputs; totalPrice, priceWithVat, vatInMoney and rounding* are computed by the calculator in beforeSave.
  5. The snapshot wins over the directory. Even for a directory customer the fields are copied to the invoice; customerId and customerContactId stay 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

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

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: currencyId missing,
  • invoiceTypeMissing: invoice type missing or unknown,
  • numberSourceMissing: neither a billing line nor a manual number,
  • numberSourceConflict: both are set (mutually exclusive, enforced by InvoiceManager::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 as customer: 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
Result

appendLines()

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|null

applyDates()

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

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>|null

countryCodeOrNull()

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|null

discountPercents()

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|null

findSeller()

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|null

intOrNull()

private intOrNull(mixed $value) : int|null
Parameters
$value : mixed
Return values
int|null

money()

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
string

numericOrNull()

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|null

paymentRegionLanguageId()

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|null

resolveBillingLineId()

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|null

resolvePaymentRegionId()

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|null

resolveUseVatOverride()

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|null

settingInt()

private settingInt(string $key, int $default) : int
Parameters
$key : string
$default : int
Return values
int

settingIntOrNull()

private settingIntOrNull(string $key) : int|null
Parameters
$key : string
Return values
int|null

settingStr()

private settingStr(string $key, string $default) : string
Parameters
$key : string
$default : string
Return values
string

stringOrNull()

private stringOrNull(mixed $value) : string|null
Parameters
$value : mixed
Return values
string|null

validateDiscount()

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|null

validateLines()

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>
Return values
string|null
On this page

Search results