The confusion sits in one line of WooCommerce's data model — there is no field that distinguishes when the order was placed from when it became revenue from when the money actually arrived in the current account. wp_posts.post_date carries the order timestamp; everything else is reconstructed from order notes and the _paid_date meta key, which is fine until the CA asks why March looks ₹4.6 lakh lighter than Razorpay says it was.
That CA is Aakash. He has been doing the books for one of our D2C clients for the better part of three years, prefers WhatsApp voice notes over email (which we have given up fighting), and on the morning of 4 April 2024 he refused to sign off on the March GSTR-1 until we explained why 11 orders had vanished from the return period. We thought it was a sync bug. It wasn't. It was a model bug, and we had been carrying it for about eighteen months without noticing because the gap was small enough every month to look like rounding.
It wasn't rounding.
Why we used to post one Sales voucher per WooCommerce order
The original setup was the obvious one. WooCommerce order moves to processing or completed, a webhook fires, our middleware posts a Sales voucher to Tally with the order total, the GST split, and the customer ledger. One order, one voucher. Clean. Easy to debug. Easy to explain to anyone looking at the books for the first time.
It worked, give or take, for about a year. The give-or-take is the load-bearing phrase in that sentence.
What we hadn't thought hard enough about: the WooCommerce order_status transition from pending → processing → completed doesn't map cleanly onto a single accounting event. Most India-focused WooCommerce setups generate the tax invoice number when the order hits processing (that's when the gateway has confirmed payment intent, usually). But the actual money — the captured amount on Razorpay's side — has its own timestamp, and the settlement into the current account is a third event, two banking days later. The fourth event, the one we ignored entirely, is the MDR-and-GST-on-MDR deduction that means the amount hitting the bank is never the amount on the invoice.
So one Sales voucher per order was booking ₹100 of revenue on day zero, and then — whenever someone bothered to reconcile — there would be a ₹98.82 entry on the bank statement that nobody had told Tally how to relate back to the original order. The bookkeeper was reconciling by hand. Every month. For about three days. We didn't know this until Aakash mentioned it in passing, because the client had been paying him to absorb the cleanup as part of his retainer and nobody had flagged it upstream.
(Aside: this is a recurring pattern. The CA absorbs the mess silently for two years and then one filing forces it into the open. Usually it's a year-end issue. In this case it was March, which made it worse.)
Why we now post three vouchers and sometimes a fourth
The model we landed on, after a Sunday evening on a video call with Aakash where he kept pausing to send 90-second voice notes that we then had to replay at 1.5x to keep up, splits each WooCommerce order into up to four distinct Tally events:
- Sales voucher — posted at tax-invoice issuance. This is the
processingtransition, notcompleted. The invoice number is generated here; the date used is thepost_datein IST, normalised. The voucher books the full invoice value (say ₹100) against the customer ledger and the appropriate output GST ledgers. - Receipt voucher — posted at payment capture. This is keyed off Razorpay's
captured_attimestamp, not the webhook delivery time. The amount is the captured amount (₹100), debiting the Razorpay clearing ledger and crediting the customer ledger. The customer ledger now nets to zero for that order, which is what you actually want. - Journal voucher — posted at settlement, when Razorpay's T+2 payout differs from the captured amount because of MDR and GST on MDR. On a ₹100 order with 1% MDR and 18% GST on MDR, the bank receives ₹98.82. The journal moves ₹1.00 from the Razorpay clearing ledger to a Bank Charges expense ledger, ₹0.18 to a GST input ledger (because that 18% on MDR is creditable, assuming the client is registered and the invoice from Razorpay supports it), and ₹98.82 from the clearing ledger to the bank ledger. The exact MDR rate varies by instrument — UPI is effectively zero on most plans, cards sit higher, netbanking is somewhere in between — so the journal amounts shift per payment, which is the whole reason it can't be hard-coded.
- Refund voucher — only when applicable. Credit note on the sales side, payment voucher on the cash side. The credit note has to reference the original invoice number (per Rule 53, or whatever the current numbering rule is — we always cross-check), and the payment voucher books the refund payout against the Razorpay clearing ledger. We'll come to why this one is the worst.
The middleware does all four. The Sales voucher is posted from the WooCommerce side. The Receipt and Journal vouchers are posted from a separate worker that polls Razorpay's /payments/:id endpoint — we found the webhook payload alone is insufficient because the webhook fires at capture but doesn't tell you anything about the eventual settlement, and the settlement object is what reconciles to the bank. The payments endpoint gives you both created_at (when the payment intent was generated) and captured_at (when the money was actually captured), and the difference between them can be anywhere from seconds to several hours depending on the payment method. UPI is usually fast. Net-banking sometimes isn't. EMI can sit in created-but-not-captured for a day.
The reason this matters: created_at and captured_at can be on different sides of midnight. They can be on different sides of midnight UTC versus midnight IST, which is the same problem with extra steps. And once a quarter, give or take, they can be on different sides of 31 March.
Where this still goes wrong — refunds, partial captures, and the 31 March problem
The 4 April 2024 incident was this exact problem. 11 orders placed on 31 March, between roughly 7pm and 11:30pm IST. Customers paid on Razorpay. Razorpay's captured_at for several of them — we never did fully unpick which gateway leg caused the lag, possibly a netbanking redirect, possibly a 3DS retry — landed after 18:30 UTC on 31 March, i.e. after 00:00 IST on 1 April. Bucketed correctly in IST, those captures should have sat in April. The middleware at the time was meant to convert captured_at from UTC to IST at write time and bucket on the IST date; one code path skipped the conversion and kept the UTC date, which for those late-evening captures read as still-31-March. Eight of the eleven landed in March under that buggy path. Three landed in April through a different code path that did convert. The Sales vouchers were all in March because the invoice number generator fires on the processing transition, which is IST and was unambiguously 31 March.
So the GSTR-1 had eleven Sales vouchers in March. The bank reconciliation had eight Receipts in March and three in April. Aakash spotted it because his internal check is to tie the sum of Receipts plus open debtor balance back to the sum of Sales for the period, and it was off by the value of three orders. He sent a voice note. We listened twice. We then spent the better part of Sunday evening rewriting the timezone handling in the worker.
The fix was boring: store everything in UTC, do all date-bucketing in IST at query time, never let the boundary logic touch a naïve datetime. The interesting part is what we still don't know how to handle cleanly.
Partial captures are one. Razorpay supports authorising one amount and capturing a smaller one (rare in standard checkout, common in some flows). If the Sales voucher is booked at the full invoice value but the Receipt comes in lower, the customer ledger doesn't net out, and there's no clean signal to either issue a credit note for the differential or treat the order as partially fulfilled. We currently flag these for manual review. There are usually two or three a month. Not great.
Refunds straddling a quarter boundary are worse. And the year-end version of that is genuinely unresolved.
Consider: a customer pays via Razorpay on 31 March. The Sales voucher posts in March. The Receipt voucher posts in March. The GSTR-1 for March is filed on (say) 9 April with this sale in it, output tax paid. On 3 April the order is cancelled and refunded. Now we need a credit note — which, per the CGST rules as we understand them, belongs in the April return because that's when the credit note is issued, not in March where the original invoice sits. The Refund voucher (the Razorpay payout reversal) also lands in April. So far, mechanically defensible.
But across 31 March specifically, this gets ugly. The Sales voucher is in FY 23-24. The credit note is in FY 24-25. The customer ledger needs to be squared off across two financial years. The MDR journal entry for the original capture is in FY 23-24; the MDR reversal (if Razorpay reverses MDR on the refund, which they sometimes do and sometimes don't depending on the product) is in FY 24-25. And the GST input credit treatment on the original MDR has, by the time the refund happens, already been claimed in the March 3B.
Honestly, we have never figured out the cleanest answer to this. We end up with a manual journal entry from Aakash, a footnote in the audit file, and a quiet hope that there aren't too many of these in any given year. There usually aren't. But "usually" is doing a lot of work in that sentence, and one bad Diwali sale followed by a January return wave could put real pressure on it.
If anyone has built a clean four-voucher mapping that survives a year-end refund without manual journal entries — a mapping where the Sales voucher in March, the credit note in April, and the Receipt and Refund vouchers straddling 31 March all reconcile automatically without someone in the finance team writing a JV by hand — write to us. We'd genuinely like to see it.


