Skip to main content

Documents

A document is an invoice, a receipt, a credit note or a delivery note issued for an order. Every document is issued and voided by one class, DocumentIssuer (administrator/src/Service/Document/DocumentIssuer.php). This page explains what that class guarantees, so that you know what your listener can rely on and what a draft must pass.

The events are on Document events. The words document type, series and draft are explained there and in the Glossary.

What it does​

The actThe methodIn plain words
IssueDocumentIssuer::issue()Gives a draft a number and stores it for good
PreviewDocumentIssuer::preview()Says what a draft would become, and stores nothing
VoidDocumentIssuer::void()Withdraws an issued document. The number and the content stay.
Void and replaceDocumentIssuer::replace()Voids a document and issues its replacement as one act
CreditDocumentIssuer::issue() with a credit typeA credit note is a document of a credit type that names the document it corrects. There is no separate method.

May an extension call it​

The doors this manual documentsThe three events onDocumentBeforeIssue, onDocumentIssued and onDocumentVoided, and a document type provider. See Document events.
What the code saysNothing in DocumentIssuer addresses an extension as a caller, and nothing forbids one. Its methods are public and static and take plain arrays. Its comments mention other callers (a returns workflow, a scheduled job) without saying whether they are Alfa's own or an extension's. The code does not settle the question.
What the code does not give youNo plugin or module that ships with Alfa Commerce calls it, so there is no shipped example. issue() and void() check no permission and do not read the documents_issuer setting: Alfa's own controllers check the member of staff before they call, and the order screen's issuing tasks check the setting. Only replace() checks a permission itself. Planned for 2.2.1, which is not released: issue() and void() check a permission themselves too (see each method below).
What followsUse the events. This manual does not document a call to DocumentIssuer from an extension. The rest of this page describes what the issuer guarantees, so that a listener knows what it can rely on. The classes around the issuer (CreditCalculator, IncrementalCorrection, VoidAndReplace, AutoIssuePolicy, StockBridge, OrderItemLines, OrderChargeLines) are core's own.

When it is used​

Who asksWhen
StaffOn the order's screen: issue a document, issue a credit note, void, void and replace
Alfa CommerceWhen an order enters a status that issues documents, or when a payment settles on an order whose status issues only once the order is paid. This is background work.
The REST APITo void a document and to read one as a PDF

When the shop's setting says an outside system issues its documents (documents_issuer set to external), Alfa issues nothing automatically, and the order screen's own tasks do nothing either: issue a document, issue a correction, void and replace. Each answers with a message and stops. Voiding an existing document is not switched off by the setting.

A staff issue, step by step​

What the picture means:

  • Steps 3 and 4 run before the save is open. A refusal there uses no number.
  • The order is locked first (step 5). Two documents for the same order are issued one after the other, never at the same moment.
  • The number is drawn last (step 7), inside the save. If the save fails, the number is not used.
  • The save is final at DocumentIssuer.php line 485 (the commit). The document exists from there.
  • Steps 9 and 10 run only after the save is final. When the document is issued inside a bigger save, they wait for that save. If that save is undone, they never run.
  • The stock step (step 9) cannot undo the document. If it fails, the document stays, the stock step stays owed and staff are told.

The same flow with a later void is on Event flows.

issue()​

What it does​

public static function issue(array $draft, bool $manageTransaction = true, bool $applyStockEffect = true): object

Takes a draft, asks onDocumentBeforeIssue, checks the draft, draws the next number of the series and writes the document. Returns the stored document row.

Planned for 2.2.1, which is not released: a fourth argument says who issues.

public static function issue(
array $draft,
bool $manageTransaction = true,
bool $applyStockEffect = true,
User|SystemActor|null $actor = null,
): object

What you must provide​

The draft's required keys are doc_type, id_order and lines (at least one). A credit also needs id_corrects. The main keys are listed on Document events; the full list is in the comment above issue().

ArgumentMeaning
$manageTransactiontrue: issue() saves as one unit of its own. false: the caller has already opened a database transaction. Then the draft must have been answered by beforeIssue() before that transaction was opened.
$applyStockEffecttrue: the stock step of the document's type is applied. false: only for a caller that has already moved these goods through the stock service itself, so that they are not moved twice.
$actorPlanned for 2.2.1, which is not released. A person (a Joomla User) must hold Sales Documents: Create. A SystemActor (administrator/src/Service/Document/SystemActor.php, made with the name of the policy) is the shop's own policy and asks no permission: the automatic issue on a status uses it. Left out, the request's own user is judged.

What it guarantees​

One document at a time per orderThe order's row is locked for the whole issue
Numbers without gapsThe number is drawn at the last moment, inside the save. A failed issue never uses one.
The draft is checked after the listenersA listener of onDocumentBeforeIssue may change the draft. The changed draft passes every check, as if staff had typed it.
The order is never over-documentedThe quantities and amounts of all issued sales documents together never exceed the order. Credits draw on issued sales documents. Delivery notes are counted on their own.
Kept as issuedThe stored document is never edited or deleted. See Kept as issued.
Totals from the linesThe document's totals and its summary per tax rate are worked out from its own stored lines
A delivery note carries no moneyIts amounts are stored as zero whatever the draft says
Announced only when finalonDocumentIssued is announced after the save the document belongs to is final, and never for a document that was undone

What it refuses​

issue() refuses by throwing. Nothing is stored and no number is used.

It refusesWhen it is checked
Planned for 2.2.1, which is not released: an actor who lacks Sales Documents: Create. It is a DocumentRefusedException with a sentence for staff.Before anything
A listener of onDocumentBeforeIssue that throws, or that leaves a database transaction openBefore the save
An unknown doc_type, a draft without id_order, a draft without linesBefore the save
id_replaces set by anyone but replace()Before the save
A stock step that is not possible: for example a deduction that would take stock set aside for other ordersBefore the save
An order that names no currencyInside the save
A draft that breaks a rule: a credit without id_corrects, id_corrects on a type that is not a credit, a type that requires the buyer's name or tax number and has none, a delivery note without the purpose of the movement, a line without a name, without a quantity above 0 or without unit prices, a shipping, fee or adjustment line of a sales document or a credit without the charge it bills (id_source)Inside the save
More than the order holds, or for a credit more than the corrected document can still give backInside the save
A type with no series given and no default series, a series that is unpublished, or a series of another typeInside the save
A call inside an open transaction with a draft that beforeIssue() did not answer, or a draft changed after it was answeredBefore anything

A refusal made inside the save reaches the caller as a RuntimeException whose message starts with DocumentIssuer: issue failed:. A refusal made before the save keeps its own form: the issuer's own checks throw a RuntimeException whose message starts with DocumentIssuer:, the stock check throws one whose message starts with Inventory:, and what a listener of onDocumentBeforeIssue throws reaches the caller as the listener threw it. Where Alfa has a sentence for staff, the exception chain holds a DocumentRefusedException (below).

preview()​

public static function preview(array $draft): object

Runs the same checks and the same arithmetic as issue() and stops. It opens no save, takes no lock, draws no number, checks no stock and announces no event. It never throws for something issue() would refuse: the answer has valid (false for a refusal) and reason.

The answer's properties: valid, reason, lines, totals, tax_summary, currency_code, currency_scale, number_formatted (always null), buyer, refusal_key and refusal_values.

A preview is a look at one moment. issue() checks everything again with the order locked.

void()​

What it does​

public static function void(int $documentId, string $reason, ?int $actorId = null, bool $manageTransaction = true): object

Marks an issued document as voided and records who voided it, when and why. The number and the content are not touched. There is no way back: a voided document is never issued again.

What it guarantees​

The number stays usedThe series has no gap
A credit note gives back what it claimedIf the credit note paid customer credit, that credit is taken back. If it put goods back in stock, that movement is reversed. Both happen inside the same save as the void.
A delivery note's stock stays outVoiding the paper does not bring the parcel back. The order's status decides about that stock.
Announced only when finalonDocumentVoided is announced after the save is final

What it refuses​

It refuses
An empty reason
A document that is not issued (already voided)
A document that a credit note which is not voided still correctsVoid the credit note first
A document whose order names no currency
A credit note whose customer credit cannot be taken back, for example because the customer has spent it
A credit note whose stock movement cannot be reversed, for example because the goods were sold again

Each is a RuntimeException whose message starts with DocumentIssuer: void failed:, except the empty reason, which is refused before anything else with the message DocumentIssuer: a void requires a reason. and no sentence for staff. replace() refuses an empty reason with a DocumentRefusedException instead.

Planned for 2.2.1, which is not released: the empty reason is a DocumentRefusedException too, with a sentence for staff ("Voiding requires a reason."). Then the actor ($actorId, or the request's own user when it is null) must hold Sales Documents: Change State, or the void is refused with a DocumentRefusedException and nothing is voided.

There is no "before void" event. A plugin cannot refuse a void.

replace(): void and replace​

What it does​

public static function replace(
int $sourceId,
string $reason,
callable $draftBuilder,
string $expectedFingerprint,
?int $actorId = null,
?callable $beforeNumbering = null,
): object

Voids a document and issues its replacement as one save. There are only two results: the old document still issued and no replacement, or the old document voided and the replacement issued and naming the old one (id_replaces).

It is built for Alfa's order screen, which previews the replacement in one request and confirms it in the next. The class VoidAndReplace prepares the call. It is core's own.

What it guarantees​

Its own permissionThe acting user must hold "Void and replace sales documents" (documents.replace). The rights to issue and to void do not include it. replace() checks this itself.
What staff confirmed is what is issuedThe replacement is built again with the order locked and compared with the preview staff confirmed (draftFingerprint()). A difference is refused and staff review again.
A new numberThe replacement takes the next number of its type's default series and today's date. The old document's number is never reused.
Listeners are asked firstonDocumentBeforeIssue is asked about the replacement before the save opens. After the save is final, onDocumentVoided is announced, then onDocumentIssued.
Only replace() writes id_replacesA manual void followed by an ordinary issue is not a replacement

What it refuses​

The refusals replace() states itself carry a DocumentRefusedException, itself or in the exception's chain, with a sentence for staff: an empty reason, a missing permission, a call inside another replace() or inside envelope(), a source that is not issued, that moved stock, that a live credit note corrects, that a refund has settled, or whose customer credit can no longer be taken back, a replacement with nothing left to cover, and a replacement that no longer matches the confirmed preview. Nothing is voided and nothing is issued.

Two refusals carry no such sentence of Alfa's. Both reach the caller inside a RuntimeException whose message starts with DocumentIssuer: void and replace failed:. One is a call inside an open transaction, which beforeIssue() refuses. The other is a listener of onDocumentBeforeIssue that refuses the replacement: the chain holds what the listener threw.

Credit​

A credit note is issued with issue(): a draft whose doc_type is a credit type (core.credit_invoice, core.credit_receipt, or a credit type a provider added) and whose id_corrects names the document it corrects.

What the issuer guarantees
A credit corrects a sales documentThe corrected document must be issued, belong to the same order, and be a sales document. A delivery note cannot be credited. guardCorrectable() states this rule.
Never more than was chargedThe lines of a credit cannot total more than the corrected document's total less the credits already issued against it (creditLeft()).
The corrected document's language and moneyA credit is stored in the language and the currency of the document it corrects. A draft that names another language is refused.
Where the money goes is keptcredit_destination says whether the credit is paid into the customer's credit. It is kept with the number and cannot be set on any other type.

On the order screen, Alfa sizes a credit with CreditCalculator while the order is locked, so that two credits drafted at the same moment cannot both take the same amount. That workflow is core's own.

Numbering and series​

RuleMeaning
A document is numbered in a seriesThe draft's id_series, or the default series of its type when the draft names none
The series is locked while a number is drawnTwo documents never get the same number
The number is drawn inside the save, as the last step before writingA refused or failed issue uses no number
The formatted number is kept as text on the documentChanging the series' format later does not change issued documents
A series can restart each yearFor a series scoped by year, the counter restarts at 1 when the first document of a new year is issued. The year is read in the shop's time zone.
A new series keeps its starting numberA series that has never issued keeps the starting number the merchant typed, also in its first year
Dates are set by the serverIf the server's clock is earlier than the series' last issue, the issue is refused

Kept as issued​

Once a document has its number, its content never changes. It can only be voided. There is no edit, no delete and no "un-void" anywhere in Alfa Commerce. A mistake is corrected with a credit note or with void and replace.

What is kept on the document, as it was at the issue:

KeptFrom
The buyer: name, tax number, tax office, activity, country, e-mail, addressThe draft
Each line's name, quantity, prices, tax rate and totalsThe draft
Each line's item, combination and the limit of its stock movementThe draft. The stock step later moves what the document kept, not what the order holds then.
The formatted number, the series, the issue dateThe issue
The currencyThe order's. For a credit, the corrected document's.

Tax names are kept at issue​

Since 2.2.0 each document line keeps the name of the tax it was charged with, next to the rate. A draft cannot state it; the issuer takes it from where the line's money comes from:

The lineIts tax name comes from
An item lineThe order line, as kept when the order was placed
A shipping lineThe parcel's own kept taxes
A line of a creditThe document it corrects, and nowhere else
A fee, an adjustment or a free-text lineNone

A document issued before 2.2.0 has no tax names. A credit that corrects such a document has none either. The tax rules are never read again at issue, so renaming a tax later does not change a document.

A PDF needs the dompdf library​

PdfRenderer::pdfBytes(int $documentId): string returns a document as PDF bytes. It draws the same page as the print view (DocumentRenderer::render(int $documentId): string) and hands it to the dompdf library.

What you must provideIn 2.2.0, the Joomla library lib_dompdf, installed so that libraries/dompdf/autoload.inc.php exists. It is not part of the 2.2.0 package. Planned for 2.2.1, which is not released: nothing. dompdf and the four libraries it needs (php-font-lib, php-svg-lib, masterminds/html5, sabberworm/php-css-parser) are installed with Alfa Commerce, each a Joomla library of its own, and Joomla's autoloader loads their classes.
What happens without itpdfBytes() throws a RuntimeException: "the dompdf library is not installed (install lib_dompdf)". The print view still works. Planned for 2.2.1, which is not released: the libraries are asked for before anything is drawn. PdfRenderer::libraryInstalled() asks for one class of each library in PdfRenderer::LIBRARIES. When one of them has been removed, pdfBytes() throws PdfLibraryMissingException (a RuntimeException) with the message "The dompdf library (lib_dompdf), or a library it needs, is not installed: PDF documents need them." The administrator's PDF and the REST API's PDF route then answer 503 with a sentence that names the library and says that installing the Alfa Commerce package again restores it.
What it guaranteesNothing is stored. The PDF is made again from the stored document on every request, so it cannot differ from the document. A voided document is drawn with its "voided" mark.
What a layout may useThe same page feeds the print view, the PDF and the e-mail attachment, and the library draws a limited set of CSS. The rules are in the comment at the top of administrator/layouts/documents/partials/style.php. Planned for 2.2.1, which is not released: a font that the page declares with @font-face is written to the renderer's temporary folder (com_alfa_dompdf in Joomla's temporary folder), never into the library's own fonts folder, which the file check under Tools > Security covers. The fonts that come with dompdf are still read from the library.

Both renderers are core's own. They read; they change nothing.

DocumentRefusedException​

Many refusals carry two texts: a technical message for the log, and a sentence for the member of staff. The second travels as a language key with its values in DocumentRefusedException (Alfa\Component\Alfa\Administrator\Service\Document\DocumentRefusedException).

MemberMeaning
__construct(string $message, string $key, array $values = [], ?Throwable $previous = null)The technical message, the language key of the sentence, its values
DocumentRefusedException::in(Throwable $e): ?selfThe first such refusal in an exception's chain, or null. The issuer wraps what it refuses in its own exception, so look in the chain.
operatorText(): stringThe sentence, translated. Not escaped.
key(): string, values(): arrayThe language key and its values

This is how Alfa's own order screen shows a refused issue (administrator/src/Controller/OrderController.php):

} catch (Throwable $e) {
$refusal = DocumentRefusedException::in($e);
$app->enqueueMessage(htmlspecialchars($refusal?->operatorText() ?? $e->getMessage(), ENT_QUOTES, 'UTF-8'), 'error');
}

A listener of onDocumentBeforeIssue that wants staff to read a translated sentence throws this exception. See onDocumentBeforeIssue.

The other public methods​

They exist for Alfa's own screens and workflows. They are listed so that you can follow the code.

MethodWhat it does
beforeIssue(array $draft): arrayAsks onDocumentBeforeIssue about a draft and returns the answered draft, sealed. Refuses when a database transaction is open. For a caller that issues inside its own transaction: ask first, open the transaction, then hand the answer to issue().
envelope(callable $work): mixedRuns a caller's issuing work in one save, and what that work owes after it. Cannot run inside another envelope() or inside replace().
previewReplacement(int $sourceId, callable $draftBuilder): objectWhat a replacement would be, with the source counted as voided. Stores nothing.
guardCorrectable(object $target, int $orderId): voidThrows when a document cannot be corrected by a credit
creditLeft(object $target): MoneyWhat a sales document can still be credited for
linesTotal(array $lines): floatWhat a credit's lines give back in total, tax included
draftFingerprint(array $draft, object $preview): stringThe identity of a previewed draft, used by replace()
buyerBlock(array $draft): array, frozenBuyer(object $document): arrayThe buyer as a draft states it, and as a stored document keeps it
DocumentIssuer::ASKEDThe draft key that carries the seal of beforeIssue(). Leave it alone.

What happens next​

AfterAlfa does
An issueClears the notices that waited for this document, applies the stock step of the document's type, announces onDocumentIssued
A voidAnnounces onDocumentVoided
A void and replaceAnnounces onDocumentVoided, then onDocumentIssued

All of it only after the save is final. A listener that throws there is logged, and the others still run.

What can go wrong​

What you seeThe usual cause
"inside an open transaction a document is issued only from a draft onDocumentBeforeIssue answered before that transaction began"issue() was called inside a transaction without beforeIssue() first
"the draft was changed after onDocumentBeforeIssue answered it"The answered draft was changed before it was handed to issue()
"an onDocumentBeforeIssue subscriber left a database transaction changed"A listener opened a transaction and did not close it. Alfa undid it and refused the issue.
A document is issued and the stock did not moveThe stock step failed after the save was final. It stays owed. Staff find it under Tools > Database Sync. Planned for 2.2.1, which is not released: when a stock plugin refused it on purpose, a task "Stock movement of a document that a stock plugin refused" stands in Tools > Tasks with Run again; any other failure is tried again by itself. See Stock events.
"the dompdf library is not installed"lib_dompdf is missing. Planned for 2.2.1, which is not released: the message is "The dompdf library (lib_dompdf), or a library it needs, is not installed: PDF documents need them." One of the libraries that ship with Alfa Commerce has been removed. Install the Alfa Commerce package again.
A document type cannot be issued automaticallyThe type has no default series
onDocumentIssued did not fireThe save the document belonged to was undone, or the shop's documents are issued by an outside system

Changed in 2.2.0​

  • Since 2.2.0. Each document line keeps its tax name, taken from the order line, the parcel or the corrected document.

Not documented yet​

  • The full list of draft and line keys, with the rule each must pass.
  • Issuing, voiding or crediting from an extension. The code does not settle whether an extension may call DocumentIssuer, and no shipped extension does it.
  • The credit calculation (CreditCalculator), the proposed corrections (IncrementalCorrection, CorrectionStrategies) and the order screen's void and replace workflow (VoidAndReplace).
  • Automatic issue on a status (AutoIssuePolicy) beyond what Document events says.
  • Document layouts: where a layout file goes and how a series chooses one.
  • How the lib_dompdf library is obtained and installed in 2.2.0. Planned for 2.2.1, which is not released: it is installed with Alfa Commerce, so there is nothing to obtain.