Click the refund button in the WooCommerce admin. A partial refund gets issued. The Razorpay API fires and the money starts its way back to the customer's card. The order status flips to refunded or partially-refunded. The customer gets an email. And in your tax ledger — nothing. No credit note. No sequential number. No entry that your CA will find in October when they reconcile the quarter. Four of those five things happen in under a second; the fifth doesn't happen at all, and nobody notices for a quarter.
That is the whole problem, and it is bigger than it looks, because most of the stores we see have been running this way for months before anyone notices. The refund button is wired to a payment event. A credit note is a tax event — a dated, numbered, legally sequential document that has to land in a specific GSTR-1 period. On most Indian WooCommerce stacks the two share a single trigger and nobody has thought about the gap until it is time to amend a GSTR-1 that was filed three return periods ago.
What the refund button actually does
Under the hood, when an admin issues a refund from wp-admin, WooCommerce fires woocommerce_order_refunded. That hook passes you the order ID and the refund ID. It does not pass you a credit note number, because WooCommerce does not have the concept of one. It does not pass you a document date distinct from the refund timestamp. It does not pass you the original invoice reference in any form that a GST return cares about — just the parent order, which may or may not have an invoice number attached, depending on which invoicing plugin the previous developer bolted on.
The Razorpay side is cleaner, misleadingly so. The refund.processed webhook arrives, usually within a few seconds, carrying the payment ID, the refund amount, and a status. That webhook is a payment-settlement record. It is not a credit note. We have seen more than one team treat the Razorpay refund entry as if it were the tax document, which it isn't, because a credit note under Indian GST has specific documentary requirements that a payment gateway has no business knowing about or satisfying.
Here is the shape of the failure. A store issues a partial refund of ₹2,340 on 7 August against an order placed on 29 July. The refund lands in August's books correctly from a cash standpoint. The original invoice sat in July's GSTR-1, already filed on the 11th. No credit note document is generated anywhere — not in WooCommerce, not in the invoicing plugin, not in Tally. The mismatch surfaces in October when the CA runs the Q2 reconciliation and finds the outward supply value in GSTR-1 does not match the revenue in the P&L by exactly ₹2,340 plus the GST on it. One refund. Three months to detect. A GSTR-1 amendment to fix.
Twenty-odd refunds a month on a mid-sized store, three months to detect each one, and the fix is always an amendment rather than a code change. Section 34 of the CGST Act is reasonably clear on this, even if the implementation on an e-commerce stack is not. A credit note is a document — issued by the supplier, carrying its own sequential number, its own date, and an explicit reference to the original tax invoice it is reducing. The sequence has to be continuous. The date has to be the date of issue, not the date of the underlying refund event (though in practice they are usually the same day). And the credit note has to be declared in the GSTR-1 of the month in which it is issued, which — critically — may be a different month from the invoice it references.
That last bit is where the whole thing comes apart on a WooCommerce stack. If the invoice went out in July and the refund happens in August, the credit note belongs in August's GSTR-1 with a reference back to the July invoice. The system has to know both dates, both document numbers, and the GSTIN of the recipient if it was a B2B supply. WooCommerce's refund flow knows none of this natively. The Razorpay webhook knows even less — it has a payment ID, which is not an invoice number, and it has no concept of your invoice numbering sequence or your credit note sequence (which should be separate from the invoice sequence, by the way; we've argued about this internally and we keep landing on separate counters because mixing them makes the audit trail harder to defend).
There's also the matter of what happens if the refund is issued after the GSTR-1 for the invoice period has already been filed. Which, for most stores filing on the 11th of the following month, is going to be the case for any refund issued more than ten or eleven days after the sale. Those refunds cannot retroactively amend the original return — they generate a credit note in the current period, and the original invoice stays as it was. This is correct behaviour under the law. It is also behaviour that no WooCommerce refund dialog explains to the person clicking the button, who is usually a customer-support executive, not a tax person.
Why the gap stays invisible for three to six months
Riya was going through a client's September data on a Tuesday afternoon last month and spotted four refunds in the WooCommerce order list that had no corresponding credit note entries in Tally. Not one. The store had processed the refunds, Razorpay had settled them, the customers had received their money, and the tax books had no idea any of it had happened. Two of the four were from early September and still inside the window where the GSTR-1 for that month hadn't been filed yet — we could issue the credit notes clean, with the correct September date, and they went into the return as if nothing had happened. The other two were from July and August, already past their filing dates, and those went in as current-period credit notes with references back to the older invoices. The whole clean-up took about a day and a half, most of which was reconciling the Razorpay settlement reports against the WooCommerce refund list to make sure we hadn't missed any.
The reason this stays invisible is that nothing in the default stack is looking for it. The CA sees a monthly export, usually a CSV from the invoicing plugin, and that export lists invoices. It does not list refunds, because refunds are not invoices. The founder sees the Razorpay dashboard, which balances perfectly, because Razorpay's job is to balance payments, not to maintain your tax register. The customer service team sees happy customers who got their money back. There is no human in the loop whose job it is to notice that a tax document should have been generated and wasn't — until the quarterly reconciliation, when a sharp CA notices the delta.
(Aside: the worst version of this we saw was a store where the previous developer had set the invoicing plugin to not auto-generate anything for refunds, specifically because the plugin's refund-handling was buggy and was generating credit notes with duplicate numbers. The fix was to turn off credit note generation entirely. Which solved the duplicate-number problem by swapping it for a no-documents-at-all problem. That store ran like that for fourteen months.)
So what we wire up before handing a store over, more or less by default now:
- A
credit_notescustom table in the WordPress database. Separate fromwp_posts, separate from the invoicing plugin's internal state. Fields for credit note number, date, original invoice reference, order ID, refund ID, taxable value, CGST, SGST, IGST, place of supply, recipient GSTIN if applicable. One row per credit note, immutable once written. - A separate counter for the credit note sequence. Not shared with the invoice counter. Stored in a dedicated table row, not in
wp_options(which is cached and gets ugly under concurrency). The counter resets on 1 April along with the invoice counter, following the financial year, with a prefix that distinguishes it from invoices — something likeCN/25-26/0001. - A hook on
woocommerce_order_refundedthat writes the credit note row atomically — same DB transaction as the refund itself, so you don't get a refund without a credit note or vice versa. If the write fails, the refund is rolled back and the admin sees an error. A loud failure here is almost always better than silent drift — the one exception we can think of is a high-volume flash sale where blocking the refund UI backs up the support queue, and even then we'd rather the error than the missing document. - A nightly cron that exports the previous day's credit notes to a shared Google Sheet the CA already uses for the invoice export. Same format, same columns, different tab. The CA does not need to learn a new tool. More often than not, the CA is the last line of defence and giving them data in the shape they already expect is worth more than any dashboard.
None of this is clever — it's the minimum wiring we can defend to a CA in October. We used to recommend doing it with whichever invoicing plugin the client already had — we stopped after the third recovery project where the plugin's internal credit note numbering had drifted or collided with the invoice sequence, and the only clean fix was to rip it out and maintain the counter ourselves.
OK so here is the one we have not solved cleanly. A customer disputes a charge with their card network two months after the order. The card network raises a chargeback. Razorpay debits our settlement account automatically to cover it. There is no refund event in WooCommerce. The admin never clicked anything. The woocommerce_order_refunded hook never fires. The order status in wp-admin still says completed. But a credit note still has to exist, because the supply has effectively been reversed and the input tax credit the buyer may have claimed needs to be matched. Right now we handle this with a Monday-morning manual reconciliation against the Razorpay dispute report, and someone enters the credit note by hand through a small admin screen we built for exactly this. It works. It is also exactly the kind of manual step that fails when the person who does it is on leave. If anyone has wired this end-to-end without a Monday-morning human in the loop — chargeback webhook straight through to a credit note document, with the right GST treatment on the reverse — we would genuinely like to hear how you did it.
