justinholtweb/craft-abacus

Sequential order numbers for Craft Commerce — real counters with prefixes, padding, date tokens and periodic resets, allocated under a lock so two checkouts can never share a number.

Maintainers

Package info

github.com/justinholtweb/craft-abacus

Type:craft-plugin

pkg:composer/justinholtweb/craft-abacus

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

5.0.0 2026-08-21 19:38 UTC

This package is auto-updated.

Last update: 2026-08-21 20:11:21 UTC


README

Sequential order numbers for Craft Commerce 5. Real counters, allocated under a lock, with a ledger that can tell you where every number went.

Out of the box Commerce hands each completed order a reference derived from its cart hash — 4b55768. It is unique, and it is useless to a customer reading it down the phone or an accountant reconciling a spreadsheet. Abacus replaces it with a number you designed: INV-2026-01042, SO-000317, 2026-08-0044.

It writes to Commerce's own order.reference, so the new number shows up everywhere the old one did — the order index, emails, PDFs, {{ order.reference }} — without a single template change.

Requirements

  • Craft CMS 5.3+
  • Craft Commerce 5.0+
  • PHP 8.2+

Installation

composer require justinholtweb/craft-abacus
php craft plugin/install abacus

Abacus installs with one disabled scheme per store. Nothing changes until you open Abacus → Schemes, set a format and switch it on — installing a plugin should never silently renumber a live store.

Editions

Lite is free and covers one sequence per store. Pro adds everything that gets interesting once you have more than one kind of order.

Lite Pro
Price Free $39, $19/year renewal
One sequence per store
Format tokens — prefix, suffix, date, store, site
Zero-padding, custom start, custom increment
Counter resets — daily, monthly, yearly
Live preview of the next three numbers
Panel on Commerce's order screen
craft.abacus.* Twig API
Console: status, preview, set
Several schemes per store, matched by order condition
Skip lists — reserve numbers and ranges
Reclaim numbers from orders that never completed
Backfill existing orders, with dry run and undo
Allocation ledger in the CP, with CSV export

The format

One field, with {number} standing in for the counter value:

Token Becomes
{number} The counter value, zero-padded to the width you set
{yyyy} {yy} Year — 2026, 26
{mm} {m} Month — 08, 8
{dd} {d} Day — 04, 4
{store} The store's handle
{site} The order's site handle

The dates are the order's dates, not today's — which is what makes a backfill of a two-year-old order land in the right year.

A format without {number} is refused, and so is an unknown token: a typo like {yyy} would otherwise be written through literally onto every invoice you print.

Counters and resets

A scheme's counter is not a single number. It is one counter per reset period, so "reset every month" actually means something:

Reset Counter buckets
Never one, forever
Every year 2026, 2027, …
Every month 2026-08, 2026-09, …
Every day 2026-08-18, 2026-08-19, …

If you reset, put a date token in the format. {number} alone with a monthly reset produces 1 in January and 1 again in February — Abacus will step past the collision rather than issue a duplicate, but the sequence will not be what you meant.

How a number is allocated

Every number — from checkout, from a backfill, from the console — comes out of one method, holding a mutex named after the scheme:

  1. Take the counter for this scheme and this reset period.
  2. Step over anything on the skip list.
  3. Render the reference and check it is free, against both the orders table and the ledger.
  4. Write the new counter value and the ledger row in one transaction.

Commerce fires EVENT_BEFORE_COMPLETE_ORDER after it has generated its own reference and before it saves the order, so Abacus replaces $order->reference in place. One save, no second write, and if Abacus declines the order keeps the reference Commerce already gave it.

That fallback is the default and it is deliberate. A locked counter should not be able to fail a checkout. If you would rather the order failed than carried the wrong number, set If a number cannot be allocated to Fail the order completion.

Know what you are choosing there. Commerce wraps markAsComplete() in a mutex named after the order but has no try/finally around the event Abacus listens on, so an exception thrown from it escapes with that lock still held. The customer sees a failed checkout and that same order cannot be retried until the lock lapses with the request. Use throw only where a wrong-format number is genuinely worse than a stuck cart — reconciliation against an external accounting system, say.

Gaps

Abacus allocates before the order element is saved. If that save then fails, the number is spent and the sequence has a hole in it. Abacus is honest about this rather than pretending:

  • Every allocation is recorded, so a hole is visible in Abacus → Numbers.
  • Pro's Reuse abandoned numbers hands that number to the next order instead, once the allocation has sat unused past the reclaim window (an hour by default, so a customer still finishing payment cannot have their number taken).
  • php craft abacus/numbers/reclaim sweeps them by hand.

The reclaim window matters. Set it to a minute and you will eventually take a number from an order that was merely slow.

Several schemes (Pro)

Schemes are tried in order and the first whose condition matches numbers the order. A scheme with no condition matches everything, so keep the catch-all last:

Order Scheme Condition
1 Wholesale Customer is in the Wholesale group
2 Big orders Total ≥ 1000
3 Orders

The condition builder is Commerce's own, evaluated against the order as it completes — so unlike a cart-time condition it can ask about order status, payment and completion date.

A condition that throws is logged and skipped rather than allowed to take a checkout down.

Backfill (Pro)

Abacus → Backfill renumbers orders that completed before Abacus arrived. It always rehearses first: pick a window, press Preview, and read the table of nowwould become before anything is written.

php craft abacus/backfill/preview --limit=500 --dateFrom=2026-01-01
php craft abacus/backfill/run --limit=500 --dateFrom=2026-01-01
php craft abacus/backfill/revert --batch=<id>

Orders are renumbered in completion order, so the sequence means what a sequence should mean. Each run is grouped under a batch ID, and each allocation records the reference the order carried before — which is what makes Put back possible.

Two things worth knowing before you run it:

  • A run recomputes rather than replaying the preview. An order that completes between the rehearsal and the run shifts the sequence honestly instead of colliding with it.
  • Reverting does not rewind the counter. The numbers the batch spent stay spent. Rewinding would mean reissuing a number that may already be printed on somebody's invoice, and a duplicate is a worse problem than a gap.

A backfill re-saves every order it touches so the search index follows the new reference. Anything else listening for order saves will hear about all of them, so run large backfills when that is acceptable.

Twig

{{ order.reference }}                          {# Abacus wrote it here — this is the number #}

{{ craft.abacus.numberForOrder(order) }}       {# null if Abacus never numbered this order #}
{{ craft.abacus.allocationForOrder(order).previousReference }}
{{ craft.abacus.next('orderNumbers') }}        {# what the next order would most likely get #}
{{ craft.abacus.preview('orderNumbers', 5) }}

Nothing in the Twig API spends a number.

Console

php craft abacus/numbers/status                       # every counter, and the next reference
php craft abacus/numbers/preview --scheme=orderNumbers --count=10
php craft abacus/numbers/set --scheme=orderNumbers --number=5000
php craft abacus/numbers/reclaim                      # Pro

php craft abacus/backfill/preview --limit=200         # writes nothing
php craft abacus/backfill/run --limit=200             # Pro
php craft abacus/backfill/batches
php craft abacus/backfill/revert --batch=<id>         # Pro

abacus/numbers/set refuses to move a counter backwards, for the same reason a revert does not.

Permissions

  • Manage numbering schemes — the Schemes section
  • View allocated numbers — the ledger and the order-screen panel
  • Renumber existing orders — Backfill

Settings

Setting Default Why you would change it
Number orders as they complete on Off leaves live checkouts alone; Abacus stays available for backfills and console work
If a number cannot be allocated keep Commerce's reference Set to fail only if a wrong-format number is worse than a stuck cart — see above
Lock timeout 5s Raise it on a store with heavy simultaneous checkouts
Reclaim after 60 min Must be longer than your slowest payment flow
Backfill batch size 100 The default the Backfill form opens with, and the bound for a console run with no --limit
Log allocations off On while setting up; noisy afterwards

What Abacus does not touch

order.number — the 32-character cart hash Commerce uses to identify a cart in URLs and cookies. It is a security token, not a customer-facing number, and rewriting it would break carts. Abacus only ever writes order.reference.

Support

justin@justinholt.com