16 min readSep 7, 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]

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, country and currency. 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_before observers 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 under string[][] every inner array was serialised as the literal string "Array", silently, with a 200 response.
  • @api SeriesInterface::getExtensionAttributes() / setExtensionAttributes(). The interface had always extended ExtensibleDataInterface, 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_at is 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_scope must 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 makes fast allocation 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 where quote and 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_reset register 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, so KOR/FV-2026-0042/0001 reconciles without opening it. Several tax regimes expect that reference on the document.
    • Available on creditmemo series only; SeriesRepository refuses 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 not invoice_id (that column is written by the Refund relation processor during save, after *_save_before fires), createByOrder() sets neither, and a credit memo read back from the database has invoice_id but no object. getInvoice() covers all three and keeps covering them if core changes when the column is populated.
    • DocumentContextInterface gains getInvoiceNumber(). 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 LengthGuard refuses the number and records why.
  • 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 (--execute writes): the spec words it as a --dry-run flag, 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 by created_at then entity_id so 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 through NumberEditorInterface, so the old number is retired and the grids stay in step; verified end to end on real data, after which qoliber:number:audit still reconciles.
  • Editing an assigned document number (spec 8), the last unbuilt element. NumberEditorInterface is @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 (through GridPool, which stays correct under async grid indexing), the permanent retirement of the old number, and an append-only edited register 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. ClaimLedger already 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:audit would then report as an unexplained gap — caused by the act of fixing a typo.
    • LockChecker decides 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.
    • NumberValidator refuses 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.
  • Qoliber_DocumentNumber::edit_number and ::edit_locked are declared again, now that something enforces them. ::edit_number is the controller's ADMIN_RESOURCE, which the framework checks before execute(); ::edit_locked is 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. AclTreeTest asserts both that they are wired and that ::edit_locked remains a SIBLING of ::edit_number — nesting is granting.
  • A "Change Number" button on the four sales document view toolbars, added through a before plugin 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.php now resolves the urn:magento:framework: urns that actually point at a module's schema - Module/etc/menu.xsd ships in magento/module-backend - so menu.xml is 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. StrictAllocator now 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:backfill ignored --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. New NumberAllocatorInterface::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 allocated row before its reserved row, so an expired checkout is allocated + reserved + expired — and checking allocated first 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. NumberValidator checked the entity and grid tables, and a live reservation is in neither — only the claim ledger knows. NumberEditor now 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 allocated row 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 own created_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. NumberAllocator records a failed row and rethrows UnresolvableContextException so 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 was ReservedOrderIdPlugin, which runs inside Quote::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.

  • NumberAllocator now records the failed row 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:audit full-scanned the register, once per counter bucket. No index led on number_space; EXPLAIN reported type=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 as type=ref, and SchemaTest pins the column order.

  • The invoice lock rule full-scanned every credit memo in the store. LockChecker asks whether a credit memo exists against an invoice; core ships no index on sales_creditmemo.invoice_id (verified with SHOW 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. SchemaTest was 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 allocated row 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 only getCurrentValue(), 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 = save on a non-order series, and of a country matcher on an order series that reserves its number — the one matcher whose data need not exist yet when a quote reserves.

  • final removed from the four matcher classes, which are DI-registered services behind an advertised extension point.

  • The series resource model ran on the default connection. 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 declares resource="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 dataAttribute element 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. componentDisabled REMOVES 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, so i18n:collect-phrases can 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 through PeriodKeyResolver while 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 through CounterScopeResolver, the same class the allocator uses.

  • Qoliber_DocumentNumber::edit_number and ::edit_locked are no longer declared. Spec 7.1 designs both for the guarded edit of an assigned number; that feature is not built, so nothing called isAllowed() 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 - and AclTreeTest still enforces that ::edit_locked must 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 separates KOR/FV-0042/0001 from KOR/FV-0042/0002. Had it not, the claim ledger would have refused the second and the refund would have fallen back to core numbering.
  • 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 quote table 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 Magento2 standard: 0 errors. Every exemption is scoped to the sniff and the path it applies to, with the reason recorded in phpcs.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.

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