11 min readOct 20, 2026by jakub

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-run lists 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:set and 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:backfill skips 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 --limit could 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 reserve mode the draw's allocated row is followed by a reserved row and a converted row, 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:set could 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 a varchar whose length prefix is already two bytes, which MySQL 8.0 applies in place without copying the register (checked on 8.0.46 with ALGORITHM=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 Magento2 standard: 0 errors and 721 warnings.
  • A Rector downgrade guard against the PHP 8.1 floor, canary-checked. Its one report is a string|false return type in an integration test: union types with false are PHP 8.0 syntax, which Rector 2.6.7's standalone-false rule 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_at is 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 in counter_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 own Creditmemo::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_scope must 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 (through GridPool, which stays correct under async grid indexing), the permanent retirement of the old number, and an edited register row naming a person and a reason. Every change requires a reason, and no counter moves.
  • NumberValidator refuses 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_number and ::edit_locked permissions, the second a SIBLING of the first rather than a child, because nesting is granting. LockChecker decides 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_sent from the document's own row, read FOR 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 states mayChangeLocked: 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. SeriesMatcherInterface exchanges matchers as a list of objects so they survive REST and SOAP, SeriesInterface declares its extension attribute accessors and getMatcherValues() for the by-code lookup, and DocumentContextInterface exposes getInvoiceNumber().
  • 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:audit reconciles 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:expire to run the reservation sweep by hand.
  • qoliber:number:counter:set to move a counter deliberately, with a mandatory reason, a confirmation prompt and a register row. The audit understands the counter_reset event it writes, so a supported manual move explains the values it skipped instead of leaving the report permanently red.
  • qoliber:number:backfill to number documents that already exist, which is what a store with years of history needs on day one. Dry run is the default and --execute writes, 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 by created_at then entity_id so 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_SHOTS is set.
  • PHPStan level 8, clean, with no baseline.
  • phpcs against the Magento2 standard: 0 errors, with every exemption scoped to the sniff and path it applies to and its reason recorded in phpcs.xml. Its 724 warnings are unchanged through development; phpcs exits 0 with ignore_warnings_on_exit=1 and 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 validate and 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.

Changelog — Document Numbering — Finance & Accounting — Extensions | qoliber Docs