If your WooCommerce invoice counter resets on 1 April, what happens to the order placed on 31 March at 23:55 IST that Razorpay marks paid at 00:02 IST on 1 April — and then, six weeks later, when the buyer asks for a refund on 14 April and you have to issue a credit note against an invoice number that, depending on which hook you trusted, now belongs to either FY24-25 or FY25-26?
That is not a hypothetical. That is roughly what happened on 2 April 2024 to a store we look after — a mid-sized apparel D2C on WooCommerce 8.6, Astra child theme, Razorpay Standard Checkout, the whole boring stack. The store issued invoice number FY24-25/0001 to an order whose payment had actually captured at 23:58 IST on 31 March. The counter-swap cron ran on UTC midnight, which is 05:30 IST on 1 April. So for those five-and-a-half hours, payments that should have closed out the old year were getting numbered into the new one. Six orders in that window. The finance lead at the client's end caught it because her 31 March daily sales report didn't tie to the GSTR-1 working she was prepping. Otherwise it would have surfaced in September, which is worse.
Anyway. The "reset the counter at midnight" design is wrong before you even get into the implementation bugs — but to see why, the old design has to be on the table.
Why we used to reset the counter at midnight on 1 April
Rule 46 of the CGST Rules is the load-bearing bit of regulation here. A tax invoice has to carry a consecutive serial number, unique for a financial year, not exceeding sixteen characters, containing alphabets or numerals or special characters hyphen or slash. That's the constraint. Everyone reads it the same way: one continuous series per FY, no gaps, no duplicates, restart on 1 April. Most of the WooCommerce GST plugins out there — we won't name them, they all make the same call — implement this as a counter stored in wp_options under some key like _invoice_counter_current, plus a stored FY prefix string, plus a scheduled action that flips both at midnight on 1 April.
That reading of Rule 46 is fine as far as it goes — the trouble is everything downstream of the stored-counter-plus-scheduled-flip design, and it shows up in three different places that don't look related at first.
The wp_options thing is the obvious one. It's not a counter, it's a key-value store with no atomicity guarantee for read-modify-write. Two orders completing within the same second — and on a Diwali-week sale this absolutely happens, we've seen double-digit orders inside a single second on a clothing store during a festival drop — will race. Both reads get counter value N. Both writes set it to N+1. Two invoices, same number. get_option / update_option is not a transaction. You can wrap it in a GET_LOCK at the MySQL level, which is what we now do, but most plugins don't bother and the bug only shows up at peak load, which means staging never catches it.
Then there's the FY rollover itself. The scheduled action that swaps the prefix typically runs via WP-Cron, which fires on page load, not on a real clock. If your store goes quiet between 23:50 IST on 31 March and 00:30 IST on 1 April — and a B2B store often does — the cron event doesn't fire until the next request comes in. Could be 06:00. Could be 09:00. Early-morning orders get last year's prefix. If you've moved to Action Scheduler with a real system cron behind it, slightly better, but then you hit the timezone problem.
Which is the one that bit our client on 2 April 2024: which clock is "midnight"? The WordPress installation has a timezone setting (under Settings → General). The PHP date_default_timezone may or may not match it. The MySQL server has its own time_zone variable, often UTC on managed hosting. The Razorpay webhook payload carries a Unix timestamp, which is timezone-agnostic, but the moment you format it for display or comparison you're back in some timezone or other. We had a store on Cloudways where WordPress was set to Asia/Kolkata, the OS was UTC, the cron was triggering on OS time, and the counter swap therefore happened at 05:30 IST. Three different clocks, one invoice. Nobody noticed until the FY ended.
The honest answer is: the design of "reset the counter at midnight" is wrong even before you get into implementation bugs. The number shouldn't depend on a wall-clock event at all. It should depend on the GST-relevant event for that specific order.
Why we now assign the number at payment capture instead
Our default now is to assign the invoice number inside the woocommerce_payment_complete hook, not woocommerce_new_order. The number does not exist on the order until the payment has actually captured. The FY prefix is derived from the capture timestamp, converted to IST explicitly via a DateTimeZone('Asia/Kolkata') regardless of what the server or WordPress thinks the timezone is, and the counter is incremented inside a MySQL transaction with a row-level lock on a dedicated wp_invoice_counters table (one row per FY, columns fy_code, last_number, updated_at). No wp_options. No scheduled swap. The row for the new FY gets created lazily, the first time a payment captures on or after 1 April IST.
The order-placement event still creates an order in WooCommerce, obviously. It just doesn't get a tax invoice number. It gets an internal order reference (#10847 or whatever), which is what the customer sees on the thank-you page and the order-confirmation email. The invoice number is generated and stamped onto the order meta when, and only when, woocommerce_payment_complete fires. For COD orders — which behave differently because there's no payment capture event in the gateway sense — we fire the same numbering routine on the transition to processing status. Not perfect; COD is a separate mess. Skip that for now.
What the design buys you, in the order it actually matters: because the FY is derived from the capture timestamp, a payment captured at 23:58 IST on 31 March gets an FY24-25 number even if the webhook is processed at 00:04 IST on 1 April. The event time decides, not the processing time. Failed orders, abandoned carts, and cancellations-before-payment never consume a number at all — which alone removes most of the "gaps in the series" panic that finance teams have in September. And because the counter sits in its own small transactional table, it's auditable; we add a razorpay_payment_id column on the same row so you can cross-reference every invoice number to the underlying gateway event without joining four tables.
The reason this matters specifically for Tally users: when the GSTR-9 prep starts in late August or early September, the CA imports the year's invoices as vouchers. Tally's voucher import — at least the version most of our clients are on, which is TallyPrime 4.x — rejects gaps in the series with a fairly unhelpful error and refuses to import the batch until you reconcile every missing number. If you've been issuing numbers at order placement, you have a gap for every cart abandonment. Hundreds of them. The reconciliation exercise is awful and it always happens in the same week the GSTR-9 is due. Numbering at payment capture means no gaps from abandoned carts. There can still be gaps from cancellations after capture, but those are rare and explicable.
(Aside: we used to push back hard on the "no gaps" requirement because Rule 46 doesn't technically require continuity, only uniqueness and consecutiveness — but the Tally import does require it in practice, and the CA does require it, and arguing the regulatory point with a CA at 9pm on 19 September is not how anyone wants to spend their evening. So we give them what they want.)
Credit notes against last year's invoices
Here's the bit we don't have a tidy answer to. A buyer places an order in March, the invoice goes out under FY24-25/0847, fine. They request a refund on 14 April. We are now in FY25-26. The credit note has to reference FY24-25/0847, but the credit note itself needs a number — and that number has to come from a series too.
The conventional advice is to run a separate credit-note series with its own FY prefix and its own counter. So you get CN/FY25-26/0001 issued against INV/FY24-25/0847. The credit note's FY is the FY in which it was issued, not the FY of the original invoice. The cross-reference goes into a dedicated meta field — we use _original_invoice_number and _original_invoice_fy so they can be queried independently — and the credit note carries both numbers on its face.
This works for B2C. For B2B it's messier, because the buyer has already claimed input tax credit on the original invoice in their March return. The credit note reverses that ITC in their April or May return. The cross-reference has to survive the buyer's accounting system too, not just yours, and you have no control over what their system expects. We've had buyers ask us to issue the credit note with the original invoice's FY prefix so their import works. We refuse, because that breaks our series. They escalate. The conversation usually ends with us emailing the credit note as a PDF and letting them key it in manually, which is exactly the kind of workaround that creates audit problems eighteen months later when nobody remembers why a particular voucher was hand-entered.
There's a related question about partial refunds — a ₹4,000 refund on an ₹11,300 invoice — where the credit note is for the differential and the original invoice technically stays live. We handle this the same way (separate CN series, cross-reference), but the place-of-supply field on the credit note has to match the original invoice, not the current default, and we've broken this twice. Once on a Maharashtra-to-Karnataka shipment where the buyer's billing address had been edited between order and refund. The CN went out with the new state. The buyer's CA noticed in July.
Which brings us to the thing we still haven't resolved cleanly. What happens when a buyer's billing state changes between order placement and shipment, after the invoice has already been issued under the wrong place-of-supply? Credit-note-and-reissue works for B2C — issue a CN against the original, issue a fresh invoice with the right state, move on. For B2B it breaks the buyer's ITC trail, because their accounting system has already booked the original invoice and the reversal-and-reissue across an FY boundary is a whole separate problem. If you've worked out a defensible approach that keeps both the CGST series and the buyer's ITC clean, write to us. We'd genuinely like to know.


