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]
Pre-1.0.0. The public API is still being settled; the entries below marked @api are the decisions that must not change after 1.0.0 is tagged.
composer.json declares 1.0.0 as the version being prepared. There is no
1.0.0 git tag yet, and this section becomes ## [1.0.0] - <date> in the
same commit that creates one - the validator compares the two and errors on
drift, so they must move together.
Added
- Series configuration: model, resource model, collection and repository, with save-time validation and an immutability policy that freezes a series' fiscal fields on its first issuance.
- Matchers for
store,website,customer_group,countryandcurrency. An unrecognised matcher code fails closed rather than being read as "no restriction". - Strict and fast counter allocation. Strict is gapless within the document's own transaction; fast commits the increment independently so contention does not serialise on the counter row.
- The claim ledger (atomic uniqueness) and the register (an append-only fiscal log of every allocation, reservation, conversion and failure).
- Document adapters:
*_save_beforeobservers for invoice, shipment and credit memo, a quote plugin for order reservation, and a sales-sequence plugin for callers that ask Magento for a number directly. - @api
SeriesMatcherInterface— matchers are exchanged as a list of objects. The previous code-keyed map could not cross the web API: Magento derives REST and SOAP types from the docblock alone, and understring[][]every inner array was serialised as the literal string"Array", silently, with a 200 response. - @api
SeriesInterface::getExtensionAttributes()/setExtensionAttributes(). The interface had always extendedExtensibleDataInterface, but declared neither accessor — a marker nothing could keep. Completing it after 1.0.0 would have been a breaking change. SeriesInterface::getMatcherValues(), restoring the by-code lookup the list shape gives up.- Composite indexes for the queries the audit, sweeper and admin grid will actually run.
- Admin UI. A series grid with a fiscal-aware actions column, a series
form documenting all eleven fields inline, and Duplicate, Archive,
Supersede and Delete flows. The actions column offers Delete only while
first_issued_atis NULL and Archive only once it is set; the repository enforces the same rule server-side, because a hidden button is a hint and not a guarantee. - Per-scope counters (
counter_scope). A series can now run one counter per rendered scope - per store, per website, per any token combination - instead of one counter for the whole series. An empty scope delegates to the period-key resolver and produces byte-identical keys to the previous behaviour, so existing series keep their counters. - Format safety rules, enforced at save: every token named in
counter_scopemust also appear in the format, and a reset policy must carry every component of its period - yearly needs a year token, monthly a year and a month, daily all three. Without these a counter resets under a number that cannot show which bucket it came from, and two documents legitimately share a number. - Supersede: closing a series and opening its replacement in one transaction, behind an ownership guard, recording the pointer on the successor.
- An append-only register grid and its menu entry.
bin/magento qoliber:number:audit- reconciles every counter against the register and explains each gap: a failed save, an expired reservation, a hand-moved counter, or - the one that matters - nothing at all. Exits non-zero when a number cannot be accounted for, so it works as a fiscal alarm in cron or CI rather than only as something to read. This is what makesfastallocation defensible, and the admin's own allocation-mode help text has been promising it.bin/magento qoliber:number:reservations:expire, also wired as 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, which is not redundant with the quote check: a quote goes inactive the moment an order is placed, and sweeping on that alone would race the conversion and record "never used" about an order that exists. The quote is queried on its own connection rather than joined, so the sweep still works on a split-database install wherequoteand the register do not share a database.bin/magento qoliber:number:counter:set- moves a counter by hand, with a mandatory reason, a confirmation prompt and a register row. The admin's "Reset counter" action shares its write path, so the counter and its explanation can never come apart.- A
counter_resetregister event, and the audit understands it: a counter moved by hand explains everything it skipped, instead of leaving the report permanently red after a supported operation. etc/webapi.xml, exposing the series repository over REST behind the same ACL the admin uses. The register is deliberately not exposed.i18n/en_US.csv.{invoiceNumber}— a credit memo can carry the number of the invoice it credits, soKOR/FV-2026-0042/0001reconciles without opening it. Several tax regimes expect that reference on the document.- Available on creditmemo series only;
SeriesRepositoryrefuses it on any other document type at save, so the mistake surfaces once with a clear message rather than declining every document of the series at allocation. - Refused in
counter_scope: one bucket per invoice would give every credit memo the number 1. - It declines rather than rendering empty, which is the opposite of what
the other context tokens do and is deliberate. They feed the scope
resolver, which decides; this one renders into the number, where an empty
segment produces
KOR//0001— a number that has silently lost the one thing it was configured to carry. A credit memo raised against the order rather than an invoice is a legitimate Magento flow, so it keeps core numbering and the register records why. - The invoice is resolved through core's own
Creditmemo::getInvoice()rather than by reading the relation by hand, because WHAT IS LOADED DEPENDS ON WHERE THE CREDIT MEMO CAME FROM:createByInvoice()sets the invoice OBJECT and notinvoice_id(that column is written by the Refund relation processor during save, after*_save_beforefires),createByOrder()sets neither, and a credit memo read back from the database hasinvoice_idbut no object.getInvoice()covers all three and keeps covering them if core changes when the column is populated. DocumentContextInterfacegainsgetInvoiceNumber(). That interface is @api and a method cannot be added after 1.0.0 is tagged, so the timing is deliberate.- Sample contexts (save-time validation, and the grid's next-number preview)
supply a representative invoice number. Without it a creditmemo series
using the token could not be SAVED, because validation renders the format
and there is no credit memo in hand — found by the integration test, not by
reasoning. The sample is representative rather than worst case, so a store
with unusually long invoice numbers can pass save-time length validation
and still hit the 50-character limit at allocation, where
LengthGuardrefuses the number and records why.
- Available on creditmemo series only;
bin/magento qoliber:number:backfill— renumbers documents that already exist, which is what a store with years of history needs on day one. Dry run is the default (--executewrites): the spec words it as a--dry-runflag, which would make the destructive mode the one you get by forgetting an argument, on a command that permanently retires every number it replaces. It prints the full plan first — including each document it will skip and why — orders bycreated_atthenentity_idso the oldest document gets the lowest number, and draws through the normal allocator so the counter, claim and register advance exactly as for a live document. Locked documents are never renumbered in bulk. Every change goes throughNumberEditorInterface, so the old number is retired and the grids stay in step; verified end to end on real data, after whichqoliber:number:auditstill reconciles.- Editing an assigned document number (spec 8), the last unbuilt element.
NumberEditorInterfaceis @api so that any correction tool goes through the same 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 an append-onlyeditedregister row naming a person and a reason. Every partial application is its own fiscal failure, which is why they share a transaction rather than merely running in order.- The replaced number is retired forever.
ClaimLedgeralready consulted that list on every allocation, so neither another edit nor the allocator can bring a number back. This is the whole argument for allowing edits at all. - Counters are never touched. An edit consumes no counter value.
Advancing one to "cover" a hand-chosen number would make the next
allocation skip a value
qoliber:number:auditwould then report as an unexplained gap — caused by the act of fixing a typo. LockCheckerdecides whether the number has likely been seen outside the store: 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 permission check.NumberValidatorrefuses an empty number, one over 50 characters, one identical to the current value, one outside[A-Za-z0-9/\-_.], one that is retired, and one another document already holds — checked in the grid table as well as the entity table, because with async grid indexing the grid can hold a row the entity table has not caught up with.
- The replaced number is retired forever.
Qoliber_DocumentNumber::edit_numberand::edit_lockedare declared again, now that something enforces them.::edit_numberis the controller'sADMIN_RESOURCE, which the framework checks beforeexecute();::edit_lockedis checked explicitly afterwards, because WHICH permission an edit needs depends on the document and the document is not known until the request has been read.AclTreeTestasserts both that they are wired and that::edit_lockedremains a SIBLING of::edit_number— nesting is granting.- A "Change Number" button on the four sales document view toolbars, added
through a
beforeplugin on Magento's button toolbar because those buttons are built inside each view block's constructor and have no layout hook. dev/validate-module-xml.phpnow resolves theurn:magento:framework:urns that actually point at a module's schema -Module/etc/menu.xsdships inmagento/module-backend- somenu.xmlis checked rather than skipped.
Fixed
Seven correctness issues found by review on 2026-09-07, before any release.
-
Strict allocation could race during checkout reservation. The counter upsert and the read that follows it are only atomic while a transaction holds the row lock between them. A document save provides one;
Quote::getReservedOrderId()does not, so each statement committed on its own and two concurrent checkouts could read the same value — one then lost the uniqueness claim and fell back to core numbering, leaving a burned number.StrictAllocatornow opens its own transaction when the caller has none, and joins the caller's when there is one. Committing unconditionally would have turned strict mode into fast mode, so both halves are asserted. -
qoliber:number:backfillignored--series. It called the general allocator, which picks a series by MATCHING — so it could permanently renumber documents through a higher-priority series, or skip them entirely, while reporting the series the operator named. NewNumberAllocatorInterface::allocateFromSeries()draws from the series the caller chose; everything else about the draw is unchanged. -
The audit counted abandoned numbers as real documents. Every reservation writes an
allocatedrow before itsreservedrow, so an expired checkout is allocated + reserved + expired — and checkingallocatedfirst reported it as a document with no finding at all. A fast-mode document whose save rolled back leaves the same shape. Only a row LINKED to an entity now proves a document exists; an unlinked allocation is reported as an unexplained gap. The audit was under-reporting exactly the gaps it exists to surface. -
Manual edits bypassed reservation ownership.
NumberValidatorchecked the entity and grid tables, and a live reservation is in neither — only the claim ledger knows.NumberEditornow CLAIMS its replacement number inside its own transaction, so ownership is atomic rather than check-then-act, and the validator consults the ledger so the refusal names the reason. -
A failed backfill still consumed a strict number. Allocation ran before the editor's transaction, so a validation or save failure left the counter, claim and
allocatedrow committed while the document kept its original number. Both now share one transaction per document, and a successful backfill links its allocation to the entity. -
Reservations were dated by cart creation. A cart created in December and checked out in January drew from December's counter bucket and was matched against December's activation window. A reservation is now dated by its own moment;
fromDocument()still uses the document's owncreated_at. -
Reservation expiry could process the same empty batch forever. The active-quote filter runs in PHP, so a single capped query meant that if the oldest N candidates were all live checkouts, every run selected and discarded exactly those and expired nothing. The sweeper now pages with a cursor that advances past what the filter removed.
-
A declined allocation crashed the document save instead of falling back to core numbering.
NumberAllocatorrecords afailedrow and rethrowsUnresolvableContextExceptionso the CALLER can fall back (spec 6.9) — and none of the three callers caught it. A counter scope on{billingCountry}plus one order with no billing address meant a fatal error saving the invoice. Worst of the three wasReservedOrderIdPlugin, which runs insideQuote::getReservedOrderId()during checkout, so the escaping exception broke the customer's checkout rather than merely mis-numbering a document. All three now fall back as documented. The allocator's own test asserted the throw and stopped there; nothing exercised the callers, which is why this survived three reviews. -
NumberAllocatornow records thefailedrow when a format token declines, not only when a scope token does. By that point the counter has already moved, and in fast mode it is already committed — so without this a declining token would burn a value permanently and leave the audit with an unexplained gap, exactly what spec 6.3 promises never happens. -
qoliber:number:auditfull-scanned the register, once per counter bucket. No index led onnumber_space;EXPLAINreportedtype=ALL, key=NULL. The register is append-only and grows for the life of the store, so the merchant most in need of an audit is by definition the one whose register is largest — this is invisible on a dev store and decisive on a real one. Added(number_space, period_key, event_type), which serves the per-bucket read as a leftmost prefix and the counter_reset ranges in full. Both now plan astype=ref, andSchemaTestpins the column order. -
The invoice lock rule full-scanned every credit memo in the store.
LockCheckerasks whether a credit memo exists against an invoice; core ships no index onsales_creditmemo.invoice_id(verified withSHOW INDEX, not assumed), so that ran on every render and submit of the change-number form. This module now adds that one index — an index and nothing else, on a core table, reverted by declarative schema on uninstall.SchemaTestwas tightened to allow exactly that shape and to fail if a column or constraint is ever added to a core table. -
Reservations made in an earlier request than the placement are no longer lost. The correlation id lived only in memory on the quote, so a checkout that reserves and places in separate requests — PayPal Express — left the reservation unlinked forever, and the sweeper would later mark it expired: an audit reporting a gap for an order that exists. The reservation is now recovered by the number the order carries.
-
A first-issuance deadlock. The claim and register inserts took shared locks on the series row and the first-issuance UPDATE then needed an exclusive one, so two concurrent first allocations each held S and wanted X. The exclusive lock is now taken first.
-
A fast-mode register row could roll away while its number stayed burned. In fast mode the counter increment is already committed independently, so the
allocatedrow is now written durably too — a register row is at least as durable as the increment it describes. -
Series configuration changes now reach long-running consumers. The series cache was process-lifetime rather than request-lifetime, so with async order management a consumer went on numbering against the rules it read at startup — and such a number can never be corrected afterwards.
-
Obtaining a sequence no longer allocates a number. Anything calling
getSequence()to inspect it, or reading onlygetCurrentValue(), used to burn a number and leave a register row no document would ever claim. -
Save-time rejection of an empty series code (which became an empty number space shared by every other blank-coded series), of
order_allocation = saveon a non-order series, and of acountrymatcher on an order series that reserves its number — the one matcher whose data need not exist yet when a quote reserves. -
finalremoved from the four matcher classes, which are DI-registered services behind an advertised extension point. -
The series resource model ran on the
defaultconnection. It passed the connection name as a third argument to_init(), which takes exactly two and discards the rest silently. Every table in this module declaresresource="sales", so on a split-database install the series table lives in the sales database while this resource model queried the default one. On a single-database install both names resolve to the same adapter and nothing could tell - the whole suite passed either way. Found by static analysis, not by a test. -
Durable register writes could block for 50 seconds and then lose the row. The claim and log tables carried foreign keys to the series table, and InnoDB takes a shared lock on the parent row to check one. With a dedicated register connection configured, that shared lock waited on the exclusive first-issuance lock held by the document's own transaction - the same PHP process waiting on itself, to a lock-wait timeout. The foreign keys are gone: the register is append-only history and an issued series can never be deleted, so they protected nothing while causing this and the original deadlock both.
-
The series form returned HTTP 500 in developer mode only. It declared a
dataAttributeelement that is not in the ui_component XSD. Magento validates that XML against its schema in developer mode ALONE; in default and production mode the invalid element is dropped silently and the form renders. Replaced with a button block, which is how core declares one. -
The form's data provider used the collection's FIRST row as the series being edited, so a new series inherited another's field metadata and an issued series could be shown an unissued one's.
-
Form metadata now marks frozen fields
disabled.componentDisabledREMOVES the field from the form rather than greying it out, which hid the fields it was meant to protect. -
The grid's preview column dereferenced
getDefaultStoreView()per row without a null check. Null is the state a fresh install is in before a store group has a default - the column would have fataled the very grid a merchant needs in order to fix that. It now falls back to the admin store, and resolves the store once rather than once per row. -
Document type, event type and matcher-scope option labels are literal
__()calls again, soi18n:collect-phrasescan extract them. Held in constants they never reached the translation CSV and stayed English in every locale. -
The admin grid and the format preview read the wrong counter for a series with a
counter_scope. Both resolved the bucket throughPeriodKeyResolverwhile the allocator files under the RENDERED SCOPE, so the grid could show "next 1" beside a series that had issued hundreds, and the preview promised a number the next document would not get. Both now go throughCounterScopeResolver, the same class the allocator uses. -
Qoliber_DocumentNumber::edit_numberand::edit_lockedare no longer declared. Spec 7.1 designs both for the guarded edit of an assigned number; that feature is not built, so nothing calledisAllowed()against either. A grantable permission that governs nothing tells an administrator they have drawn a line that does not exist. They return with the feature - andAclTreeTeststill enforces that::edit_lockedmust be a SIBLING of::edit_number, never a child, because nesting is granting. -
The admin's "Reset counter" action was a stub that declined. It now resets the current bucket for real, POST-only, behind a confirmation, writing a register row against the admin user - and it resolves the bucket the way the allocator does, so it clears the counter that is actually in use.
Changed
Development-time only; nothing below changes behaviour on a store.
-
The document cardinalities a real store produces are now covered. Every test in the suite created exactly one invoice, one shipment and one credit memo, so nothing proved the module numbers the SECOND one — and shipments had no integration coverage at all, despite being numbered in production through
sales_order_shipment_save_before. Added: two invoices on one order (partial invoicing), two shipments on one order (partial fulfilment), two credit memos against ONE invoice, and a check that each register row links its own entity. All four behaviours were already correct; nothing had verified them.- The credit memo case is the one the
{invoiceNumber}token makes interesting: both documents render the SAME invoice segment, so only the counter separatesKOR/FV-0042/0001fromKOR/FV-0042/0002. Had it not, the claim ledger would have refused the second and the refund would have fallen back to core numbering.
- The credit memo case is the one the
-
Fast mode's defining promise is now proven, and the suite has no skips left. Two tests previously skipped as "not verifiable in this sandbox". They were verifiable; the earlier attempt compared both allocators in the same artificial two-connection shape and correctly concluded that proved nothing. The discriminating experiment is each allocator in ITS OWN production shape — strict sharing the document's connection, fast on a separate session — and it shows them diverge: fast's increment survives the document's rollback, strict's does not. A second connection to the same database is sufficient, because MySQL gives it its own session. Wiring fast onto the document's connection fails the test, so it discriminates.
-
A source-level guard that the reservation sweeper never joins the
quotetable into its candidate query. That join is valid SQL on a single-database store and fails outright on a split one, so no behavioural test in this sandbox could ever catch it; reading the file is the only assertion that can fail. Reinstating the join fails the guard. -
PHPStan raised from level 5 to level 8, clean, with no baseline. The two faults above under "Fixed" - the wrong connection and the nullable store view - are both things it caught and the test suite did not.
-
phpcs against the
Magento2standard: 0 errors. Every exemption is scoped to the sniff and the path it applies to, with the reason recorded inphpcs.xml, rather than a blanket suppression. -
A Rector floor guard proving the code is valid on the PHP 8.1 floor it claims, and a parse check across 8.1, 8.2, 8.3, 8.4 and 8.5.