Rule 46(b) and the WooCommerce Invoice Counter: Where the Plugins Get It Wrong

Most Indian WooCommerce stores issue sequential invoice numbers in a way that silently breaks Rule 46(b) of the CGST Rules the moment concurrency, failed payments, or a second storefront enters the picture. Here is what the rule actually requires, where the common plugins fall over, and the two counter patterns we use instead.

19 October 2026·9 min read·Ritwik Bhattacharya

The WooCommerce GST plugin we see most often on client stores keeps the next invoice number in a single wp_options row, read-modified-written on every order save. On a store doing thirty orders a minute during a flash sale, that row is a race condition dressed up as a counter. Nothing in the plugin UI tells you that. Nothing in the setup wizard warns you. You find out on the night it matters.

On 11 October 2023 — Diwali-eve, a lightning sale on a mid-size apparel client's store — two orders came out of the checkout within the same second carrying the invoice number INV-2023-24-04471. Both got written. Both got emailed to the respective buyers. We caught it only because the client's accounts team ran a Tally import the next morning and Tally's voucher-number uniqueness constraint rejected the second row. If they had been on manual entry, or on a looser accounting package, nobody would have noticed until the GSTR-1 reconciliation at month-end. Possibly later.

That one incident is why we rewrote how we think about invoice numbering on WooCommerce. Not because the plugin is badly written — it isn't, really — but because the rule it is trying to implement is subtler than "give me the next integer".

What Rule 46(b) actually says, and what plugin authors assume it says

The text of Rule 46(b) of the CGST Rules, 2017 is short enough to quote in full:

"a consecutive serial number not exceeding sixteen characters, in one or multiple series, containing alphabets or numerals or special characters hyphen or dash and slash symbolised as "-" and "/" respectively, and any combination thereof, unique for a financial year"

Three things in that sentence do the heavy lifting — the sixteen-character ceiling, the requirement that the number be consecutive, and the uniqueness-within-a-financial-year clause. The sixteen-character ceiling is the one plugin authors mostly get right — hard to miss when the field validates on input. The other two are where it falls apart.

Most WooCommerce GST plugins read "consecutive serial number" as "an auto-incrementing integer, prefixed with your financial year, zero-padded to taste". So you end up with INV-2024-25-00001, INV-2024-25-00002, and so on. Which looks right. Which is right, if the counter is actually atomic and if nothing ever burns a number. Those two ifs are doing a lot of work.

The rule says "consecutive". It does not say "without gaps" in so many words, but in practice every CA we have worked with on an e-commerce file reads consecutive to mean no missing numbers in the series. Gaps are the single most common thing an auditor flags during a GST scrutiny on an e-commerce book. Not because gaps are illegal per se, but because gaps look like deleted invoices, and deleted invoices look like evasion. You then spend a fortnight producing an explanation for each gap. Even if the explanation is clean — order abandoned at payment, say — you've just handed the officer a reason to look harder.

Also: the rule says unique for a financial year. It does not say the series must restart on 1 April. We've written about the reset habit separately — the short version is that resetting is a convention plugins ship with, not a requirement the rule imposes, and resetting badly is how you end up with the new year's INV-2024-25-00001 colliding with a stale value if your financial-year prefix logic has a bug. The rule only cares that within the FY, every number is unique.

Why wp_options is the wrong place for a counter

The wp_options table was not designed for high-write, concurrency-sensitive data. It was designed for site configuration — stuff you read on every request and write once in a blue moon. The row name used by the GST plugin we see most often is wt_woocommerce_invoice_number (there are variants depending on the plugin fork, but that family of names). On every order save, the plugin does roughly: SELECT option_value FROM wp_options WHERE option_name = 'wt_woocommerce_invoice_number', increments it in PHP, then UPDATE wp_options SET option_value = ....

MySQL's default AUTOCOMMIT=1 behaviour means each of those statements is its own transaction. Between the SELECT and the UPDATE there is no lock on the row. Two PHP workers handling two concurrent checkouts can read the same current value, both increment to the same next value, and both write it. First writer wins the collision silently — the second one just overwrites with the same number. Then both order rows get assigned the same invoice number by the plugin code that already has the integer in a local variable.

That is exactly the failure mode we hit on 11 October. Two FPM workers, two POST /?wc-ajax=checkout requests landing within the same tick, two reads of 4470, two writes of 4471, two orders stamped INV-2023-24-04471. The wp_options row didn't help. InnoDB didn't help, because without SELECT ... FOR UPDATE there was nothing to lock against. The plugin's internal get_option / update_option wrapper didn't help either — those sit on top of WordPress's object cache, which cheerfully returns the stale value until it is invalidated.

There is also the question of what happens when the order is created but payment then fails. Some plugins increment the counter at the woocommerce_new_order hook, which fires before the payment attempt. If the payment fails and the order lands in failed or cancelled, the invoice number has already been burned. Gap in the series. Which, see above, is the thing the CA will ask about. Other plugins defer the increment until woocommerce_order_status_completed or processing, which is cleaner — but then you have the opposite problem where orders that are legitimately invoiceable on dispatch (COD, for instance) don't get numbered until much later, and your sequential ordering gets scrambled by order of status transition rather than order of creation. Neither default is wrong exactly; both have audit implications most store owners never think about until someone asks.

What we do instead

The first pattern is the one we reach for on single-store setups. A dedicated MySQL table — call it wc_invoice_sequence — with roughly this shape:

CREATE TABLE wc_invoice_sequence (
  series_key   VARCHAR(32) NOT NULL PRIMARY KEY,
  last_number  BIGINT UNSIGNED NOT NULL,
  updated_at   DATETIME NOT NULL
) ENGINE=InnoDB;

One row per series. For a single-store setup with a per-FY prefix, series_key might be 'FY-2024-25'. The allocation happens inside the same transaction that creates the order record, using SELECT last_number FROM wc_invoice_sequence WHERE series_key = ? FOR UPDATE, then an UPDATE to increment, then the number is written to the order's invoice meta. The FOR UPDATE takes a row-level lock in InnoDB; any second worker trying to read the same row blocks until the first transaction commits. No collisions. Numbers are consecutive because the increment is serialised by the lock, not by hope.

The important bit — and this is where we have seen even careful developers mess it up — is that the allocation has to happen in the same transaction as the thing that commits the order. If you allocate inside a short transaction, commit, and then try to write the order in a separate transaction that later rolls back, you've burned a number. We wrap it as a single $wpdb->query('START TRANSACTION') block, allocate the number, write the order and its meta, commit at the end. If anything throws, the whole thing rolls back and the counter never moved.

The second pattern is for stores with more than one invoicing context on the same install — multi-outlet, or a main site plus a wholesale portal on a subdomain sharing the database. Per-outlet or per-series prefix: BLR-24-25-00001, DEL-24-25-00001, WHL-24-25-00001. Each prefix gets its own row in the sequence table — 'BLR-FY-2024-25', 'DEL-FY-2024-25', and so on — with its own locked counter. The prefixes do two things at once: they let different checkout contexts allocate numbers in parallel without contending on the same lock, and they make the series self-documenting when the accounts team or the auditor opens the register. "This BLR number was issued from the Bengaluru warehouse's dispatch flow" is a sentence a reasonable person can nod along to.

Both patterns are compliant readings of Rule 46(b), as best we understand it — the rule explicitly permits "one or multiple series", which is the legal basis for the second pattern. Neither is the only valid approach; there are shops using a BIGINT AUTO_INCREMENT on a dedicated table and reading the generated ID back, which also works and arguably works better than our FOR UPDATE dance. (One of our engineers thinks it deals cleanly with the FY rollover; the rest of us are less sure. Separate argument for a separate day.)

One honest caveat on the burn-the-number problem: even with the transactional pattern above, you still have to decide whether a cancelled-after-payment order keeps its invoice number or releases it. We keep it. Releasing it means re-using a number against a different order later, which under Rule 46(b)'s uniqueness requirement is the one thing you must never do. Keeping it means a gap, but a gap you can explain — "order 4471 was cancelled by the customer at 14:03 on 12 October, here is the credit note trail" — is a conversation. A duplicate is not a conversation; it is a finding.

Which brings us to the thing we have not resolved. When a client runs two WooCommerce stores on the same GSTIN — a main D2C site and a B2B portal, say, each on its own WordPress install, each writing to its own database — is it safer to pipe both into one shared sequence (an external service, a shared Redis-backed counter, something along those lines) so the GSTIN has a single consecutive series? Or to run two prefixed series, D2C-24-25-... and B2B-24-25-..., and present them as two of the "multiple series" the rule explicitly permits? We lean toward prefixed series. We've been argued out of it twice — once by a CA in Mumbai who said the department in his jurisdiction wanted a single consolidated register per GSTIN, and once by a client's internal auditor who said the opposite. If any reader has had this specific conversation with a GST officer and knows what the department actually asks to see when they pull the invoice register for a GSTIN with two storefronts, we would genuinely like to hear how it went.

Ritwik Bhattacharya

Written by

Ritwik Bhattacharya

Principal Engineer

Eleven years in front-end engineering, based in Gurgaon. Builds in Next.js and Node, reluctantly maintains the WordPress installs we inherit from older clients. Writes here about the engineering side of the practice — frameworks, performance budgets, and the bugs that keep coming back (usually hydration).

                    

Ready to build something that works?

Get a free website mockup for your business. No commitment, no fluff — just what it could look like.

Call us — picked up by a human+91 88820 82228WhatsApp us — replies in minutes+91 88820 82228