Changelog
Current version: 1.0.0
All notable changes to Qoliber_DocumentNumber are recorded here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Fixes to 1.0.0 and one new console option. One schema change - the register's reason column is widened - and no change to an @api contract.
Added
qoliber:number:reservations:expire --dry-runlists every reservation a real run would write off, with the reason it would record, and writes nothing. It reads through the sweeper's own selection rather than running a sweep and rolling it back, which the durably written register would survive. In 1.0.0 the option only said it was not available.
Fixed
- The admin series form dropped Counter Scope on save, so a per-scope counter could only be configured over REST. The form now saves it, and a blank field is stored as no scope, which falls back to the reset policy.
- "Reset counter" is refused when the number the current period would start
from is already held - by a document, a checkout reservation, or a number
retired by an edit. The format of a series that has issued is frozen, so
starting over rendered the numbers it had already issued. The claim ledger
refused each one, and in a strict series rolled the counter back onto the
same number, so every document of the series failed to save until somebody
moved the counter again. The refusal names the number and points at
qoliber:number:counter:setand Supersede, and the confirmation dialog no longer says the reset may produce a number a customer already holds. - A series that has never issued can no longer be made active while the next number it would issue is already held. The usual case is a successor left with its predecessor's format: 1.0.0 saved it, and in a strict series every document it numbered then failed to save. The save now names the number and the three ways out - a different format, a later counter start, or the predecessor's number space - and the series can still be saved inactive.
qoliber:number:backfillskips a document whose current number the series already issued, live or in an earlier run. 1.0.0 renumbered those again, retiring the numbers the first run had given them, so a run cut short by--limitcould not be resumed. Running the same command again now renumbers only what was left.- A fault in the admin Change Number and Reset counter actions - as opposed to a refusal, which the admin message explains - is logged at critical level with its exception. The message alone said only that the action failed.
- The README's register sizing said about four rows per order and two per
abandoned checkout. In
reservemode the draw'sallocatedrow is followed by areservedrow and aconvertedrow, so it is about five, and three. - The register's reason column held 255 characters, and Magento writes with
MySQL's strict mode off, so a longer reason was cut without a word. Every
collision reason the module writes is longer than that, and a reason typed
for Change Number or
counter:setcould be too. The column now holds 1024 characters. A reason a person types is refused above that, naming its length, rather than cut, and the Change Number form says so under the field; a reason the module writes itself is cut at 1024 with a closing ellipsis, so the cut can be seen. The upgrade lengthens avarcharwhose length prefix is already two bytes, which MySQL 8.0 applies in place without copying the register (checked on 8.0.46 withALGORITHM=INPLACE, LOCK=NONE).
Validation
Verified on Mage-OS 3.3 at this commit:
- 344 unit tests / 946 assertions, executed on PHP 8.3, 8.4 and 8.5.
- 206 integration tests / 628 assertions against a real database, on a fresh install: no failures and no errors. Every field the admin series form renders is posted through the real save action and read back, so a field the form shows but the save drops fails the suite.
- 31 Playwright tests driving the admin and the web API over real HTTP.
- PHPStan level 8, clean, with no baseline.
- phpcs against the
Magento2standard: 0 errors and 721 warnings. - A Rector downgrade guard against the PHP 8.1 floor, canary-checked. Its one
report is a
string|falsereturn type in an integration test: union types withfalseare PHP 8.0 syntax, which Rector 2.6.7's standalone-falserule misreads. 252 PHP files plus one template parse on 8.1 through 8.5.
[1.0.0] - 2026-09-12
First release. There is no earlier public version, so this section describes the module as it ships rather than listing changes against one; the development history is in the repository. Entries marked @api are the decisions this release freezes: changing one of them needs a major version.
Qoliber_DocumentNumber gives orders, invoices, shipments and credit memos their own configurable numbers - per store, per website, per country, per currency, per customer group - and treats those numbers as fiscal records rather than display strings. Every number issued, reserved, replaced or refused is written to an append-only register, uniqueness is won atomically instead of checked, and a replaced number is retired for good. A document with no matching series keeps Magento's own numbering, so installing the module changes nothing until a series is configured.
Requires PHP 8.1 through 8.5, and Magento Open Source 2.4.6+ / Mage-OS
(magento/framework ^103.0, magento/module-sales ^103.0 || ^104.0).
Added
Series and matching
- Series configuration with a model, resource model, collection and repository, save-time validation, and an immutability policy that freezes a series' fiscal fields on its first issuance.
- Matching on store, website, customer group, billing country and currency. Values within one matcher are OR-ed, separate matchers are AND-ed, and the highest-priority matching series wins. An unrecognised matcher code fails closed rather than being read as "no restriction".
- Duplicate, Archive, Supersede and Delete. Supersede closes a series and opens its replacement in one transaction, behind an ownership guard, and records the pointer on the successor - the supported way to change a series that has already issued. Duplicate and Supersede carry the counter scope across with everything else.
- Delete is offered only while
first_issued_atis NULL and Archive only once it is set. The repository enforces that server-side as well, because a hidden button is a hint and not a guarantee.
Counters and allocation
- Strict allocation, gapless within the document's own transaction, and fast allocation, which commits the counter increment on its own connection so contention does not serialise on the counter row. The README documents the connection fast mode needs and how it behaves without one.
- Per-scope counters (
counter_scope): one counter per rendered scope - per store, per website, per any combination of tokens - instead of one counter for the whole series. An empty scope delegates to the period-key resolver. - Daily, monthly and yearly reset policies.
- Every token a format needs is resolved BEFORE the counter moves, so a token that declines cannot consume a value. Counters advance only through allocation or a recorded manual move; editing a number never advances one.
Formats
- Tokens for counters, dates, store and website id and code, billing country, currency, customer group, document type, and random segments.
{invoiceNumber}on credit memo series, so a refund carries the number of the invoice it credits (KOR/FV-2026-0042/0001), as several tax regimes expect. It is refused on other document types at save, refused incounter_scope, and - alone among the context tokens - declines rather than rendering an empty segment: a number that has silently lost the reference it was configured to carry is worse than core numbering, which is what such a document keeps, with the reason in the register. The invoice is resolved through core's ownCreditmemo::getInvoice(), which covers a credit memo created from an invoice, created from an order, and read back from the database.- Save-time validation of formats and scopes: every token named in
counter_scopemust appear in the format, a reset policy must carry every component of its period (yearly a year token, monthly a year and a month, daily all three), an escaped{{is read as the literal brace it renders, and a format must vary per document. - A 50-character limit on the finished number, enforced at allocation by
LengthGuard, which refuses the number and records why rather than writing a truncated one.
Integrity
- An atomic claim ledger: uniqueness is won on a unique key at insert, not checked by a lookup a moment earlier, so a concurrent checkout loses the race instead of duplicating a number.
- Permanent retirement of replaced numbers. The ledger consults the retired list on every allocation, so neither another edit nor the allocator can bring a number back.
- The register: an append-only log of every allocation, reservation, conversion, expiry, failure, manual counter move and number change, each row carrying its correlation id and, for anything a person did, that person and their reason.
Order reservations
- Numbers reserved across checkout requests and multishipping, and linked to the order once it is placed.
- An hourly cron writes off reservations whose quote is dead, so the audit can reconcile. It refuses to touch a reservation whose number an order carries - a quote goes inactive the moment an order is placed, so sweeping on that alone would race the conversion and record "never used" about an order that exists - and it queries the quote on its own connection rather than joining it, so the sweep also works on a split-database install.
Admin
- A series grid with a fiscal-aware actions column and a next-number preview that allocates nothing, and a series form documenting every field inline. Formats are validated on save; the form has no live preview.
- The register as its own append-only grid, with a menu entry.
- A "Reset counter" action that writes through the same path as the CLI, so a counter and its explanation cannot come apart.
- A Change Number action on the order, invoice, shipment and credit memo views.
Guarded number editing
- @api
NumberEditorInterface, so any correction tool goes through the guarantees the admin does. One transaction moves four things or none of them: the entity, the sales grids (throughGridPool, which stays correct under async grid indexing), the permanent retirement of the old number, and aneditedregister row naming a person and a reason. Every change requires a reason, and no counter moves. NumberValidatorrefuses an empty number, one over 50 characters, one equal to the current value, one outside[A-Za-z0-9/\-_.], one that is retired, and one another document holds - checked in the grid tables as well as the entity tables, because under async indexing a grid can hold a row the entity table has not caught up with.- Separate
::edit_numberand::edit_lockedpermissions, the second a SIBLING of the first rather than a child, because nesting is granting.LockCheckerdecides what counts as locked: an invoice that was emailed or has a credit memo against it, an order with any invoice, shipment or credit memo, a shipment or credit memo that was emailed. An unrecognised document type is treated as locked, because the alternative is that a type this build does not know about gets the weaker check. - The rule is judged inside the editor's transaction and against COMMITTED
state:
email_sentfrom the document's own row, readFOR UPDATE, and related documents through locking reads. That holds even when the editor runs inside a caller's older transaction snapshot, as it does under backfill. A locked document is refused unless the caller statesmayChangeLocked: true. - Overlapping edits are refused rather than merged. The admin form carries the number it showed the administrator, and the change is refused if the document no longer has it, because the reason was written about that number. Backfill likewise skips a document renumbered after its plan. A caller that states no expected number applies to the number read under the row lock, and the register records the number really replaced.
Contracts and web API
- @api PHP contracts for series configuration, allocation and guarded
editing.
SeriesMatcherInterfaceexchanges matchers as a list of objects so they survive REST and SOAP,SeriesInterfacedeclares its extension attribute accessors andgetMatcherValues()for the by-code lookup, andDocumentContextInterfaceexposesgetInvoiceNumber(). - Six REST routes over the series repository - list, read by id, read by code,
create, update and delete - every one behind
Qoliber_DocumentNumber::series, with the series identifier in the URL for update and delete. The SOAP WSDL and the REST schema generate for the service as well. The register is deliberately not exposed. i18n/en_US.csv.
Command line
qoliber:number:auditreconciles every counter against the register and explains each gap - a failed save, an expired reservation, a hand-moved counter, or nothing at all - and exits non-zero on a number it cannot account for, so it serves as a fiscal alarm in cron or CI rather than only as something to read. It is what makes fast allocation defensible.qoliber:number:reservations:expireto run the reservation sweep by hand.qoliber:number:counter:setto move a counter deliberately, with a mandatory reason, a confirmation prompt and a register row. The audit understands thecounter_resetevent it writes, so a supported manual move explains the values it skipped instead of leaving the report permanently red.qoliber:number:backfillto number documents that already exist, which is what a store with years of history needs on day one. Dry run is the default and--executewrites, on a command that permanently retires every number it replaces. It prints the whole plan first, including each document it will skip and why, orders bycreated_atthenentity_idso the oldest document takes the lowest number, and draws through the normal allocator so the counter, the claim and the register advance exactly as for a live document. Locked documents are never renumbered in bulk.
Schema
- Composite indexes for the queries the audit, the sweeper and the admin grids
actually run, and an index on
sales_creditmemo.invoice_id, which core does not ship and the lock check needs on every render of the change form.
Validation
Verified on Mage-OS 3.3 at this commit:
- 338 unit tests / 936 assertions, executed on PHP 8.3, 8.4 and 8.5.
- 186 integration tests / 551 assertions against a real database, on the stock integration configuration with a full reinstall: no failures, no errors and no risky tests. They include concurrency driven by two real processes - overlapping edits of one document - and four cases that run the editor inside an older transaction snapshot while a second connection commits underneath it.
- 31 Playwright tests driving the admin and the web API over real HTTP. Four
documentation screenshot tests, and the login they need, run only when
DOCS_SHOTSis set. - PHPStan level 8, clean, with no baseline.
- phpcs against the
Magento2standard: 0 errors, with every exemption scoped to the sniff and path it applies to and its reason recorded inphpcs.xml. Its 724 warnings are unchanged through development; phpcs exits 0 withignore_warnings_on_exit=1and 2 by default. - A Rector downgrade guard clean to the PHP 8.1 floor - checked against a planted 8.3 construct, so the guard is known to bite - and 244 PHP files plus one template parsing on 8.1, 8.2, 8.3, 8.4 and 8.5.
composer validateand the qoliber composer-shape validator: no errors.
PHP 8.1 and 8.2 are proven by the parse check and the floor guard rather than by an executed suite: PHPUnit needs 8.2 and the Mage-OS 3.3 vendor tree needs 8.3, so neither can run the suites on this sandbox. A run against Magento Open Source 2.4.6 is tracked separately and is not claimed here.