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 act | The method | In plain words |
|---|---|---|
| Issue | DocumentIssuer::issue() | Gives a draft a number and stores it for good |
| Preview | DocumentIssuer::preview() | Says what a draft would become, and stores nothing |
| Void | DocumentIssuer::void() | Withdraws an issued document. The number and the content stay. |
| Void and replace | DocumentIssuer::replace() | Voids a document and issues its replacement as one act |
| Credit | DocumentIssuer::issue() with a credit type | A 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 documents | The three events onDocumentBeforeIssue, onDocumentIssued and onDocumentVoided, and a document type provider. See Document events. |
| What the code says | Nothing 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 you | No 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 follows | Use 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 asks | When |
|---|---|
| Staff | On the order's screen: issue a document, issue a credit note, void, void and replace |
| Alfa Commerce | When 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 API | To 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.phpline 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().
| Argument | Meaning |
|---|---|
$manageTransaction | true: 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. |
$applyStockEffect | true: 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. |
$actor | Planned 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 order | The order's row is locked for the whole issue |
| Numbers without gaps | The number is drawn at the last moment, inside the save. A failed issue never uses one. |
| The draft is checked after the listeners | A listener of onDocumentBeforeIssue may change the draft. The changed draft passes every check, as if staff had typed it. |
| The order is never over-documented | The 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 issued | The stored document is never edited or deleted. See Kept as issued. |
| Totals from the lines | The document's totals and its summary per tax rate are worked out from its own stored lines |
| A delivery note carries no money | Its amounts are stored as zero whatever the draft says |
| Announced only when final | onDocumentIssued 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 refuses | When 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 open | Before the save |
An unknown doc_type, a draft without id_order, a draft without lines | Before 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 orders | Before the save |
| An order that names no currency | Inside 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 back | Inside the save |
| A type with no series given and no default series, a series that is unpublished, or a series of another type | Inside the save |
A call inside an open transaction with a draft that beforeIssue() did not answer, or a draft changed after it was answered | Before 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 used | The series has no gap |
| A credit note gives back what it claimed | If 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 out | Voiding the paper does not bring the parcel back. The order's status decides about that stock. |
| Announced only when final | onDocumentVoided 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 corrects | Void 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 permission | The 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 issued | The 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 number | The 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 first | onDocumentBeforeIssue is asked about the replacement before the save opens. After the save is final, onDocumentVoided is announced, then onDocumentIssued. |
Only replace() writes id_replaces | A 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 document | The 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 charged | The 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 money | A 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 kept | credit_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
| Rule | Meaning |
|---|---|
| A document is numbered in a series | The draft's id_series, or the default series of its type when the draft names none |
| The series is locked while a number is drawn | Two documents never get the same number |
| The number is drawn inside the save, as the last step before writing | A refused or failed issue uses no number |
| The formatted number is kept as text on the document | Changing the series' format later does not change issued documents |
| A series can restart each year | For 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 number | A series that has never issued keeps the starting number the merchant typed, also in its first year |
| Dates are set by the server | If 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:
| Kept | From |
|---|---|
| The buyer: name, tax number, tax office, activity, country, e-mail, address | The draft |
| Each line's name, quantity, prices, tax rate and totals | The draft |
| Each line's item, combination and the limit of its stock movement | The draft. The stock step later moves what the document kept, not what the order holds then. |
| The formatted number, the series, the issue date | The issue |
| The currency | The 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 line | Its tax name comes from |
|---|---|
| An item line | The order line, as kept when the order was placed |
| A shipping line | The parcel's own kept taxes |
| A line of a credit | The document it corrects, and nowhere else |
| A fee, an adjustment or a free-text line | None |
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 provide | In 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 it | pdfBytes() 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 guarantees | Nothing 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 use | The 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).
| Member | Meaning |
|---|---|
__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): ?self | The 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(): string | The sentence, translated. Not escaped. |
key(): string, values(): array | The 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.
| Method | What it does |
|---|---|
beforeIssue(array $draft): array | Asks 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): mixed | Runs 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): object | What a replacement would be, with the source counted as voided. Stores nothing. |
guardCorrectable(object $target, int $orderId): void | Throws when a document cannot be corrected by a credit |
creditLeft(object $target): Money | What a sales document can still be credited for |
linesTotal(array $lines): float | What a credit's lines give back in total, tax included |
draftFingerprint(array $draft, object $preview): string | The identity of a previewed draft, used by replace() |
buyerBlock(array $draft): array, frozenBuyer(object $document): array | The buyer as a draft states it, and as a stored document keeps it |
DocumentIssuer::ASKED | The draft key that carries the seal of beforeIssue(). Leave it alone. |
What happens next
| After | Alfa does |
|---|---|
| An issue | Clears the notices that waited for this document, applies the stock step of the document's type, announces onDocumentIssued |
| A void | Announces onDocumentVoided |
| A void and replace | Announces 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 see | The 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 move | The 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 automatically | The type has no default series |
onDocumentIssued did not fire | The 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_dompdflibrary 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.