Why We Add a Place-of-Supply Field to Every WooCommerce Checkout in India

WooCommerce has no place-of-supply field. It has a billing state and a shipping state, and the common GST plugins quietly pick one for you. That is fine for a plain B2C order and silently breaks for B2B interstate invoices, export of services, and bill-to-ship-to. Here is the pattern we now use on every India build — and the one case we still have not solved.

11 October 2026·11 min read·Ritwik Bhattacharya

The place-of-supply field in WooCommerce does not exist. There is a billing state and a shipping state, and the common GST plugins pick one of them with a filter the shop owner never sees. For a plain domestic B2C sale, that is usually fine. For almost everything else — a B2B interstate invoice with a GSTIN, an export-of-services receipt, a bill-to-ship-to arrangement across two states — it is a slow-motion compliance problem that nobody notices until the GSTR-1 is being reconciled against the sales register and the numbers do not tie.

Riya spotted this during a tie-out in July 2024. A client was reconciling the previous quarter's GSTR-1 against their internal sales register and the tax split was off by a chunk that mattered. Eleven invoices to a single B2B buyer in Gujarat had been issued with CGST+SGST instead of IGST. The billing address on file was the buyer's Mumbai head office — that was the address they had put in during the first checkout, back when they set up the account — but the GSTIN they wanted the invoice against was registered in Ahmedabad. The plugin, doing exactly what it had been told to do, took the billing state (Maharashtra), compared it to the seller state (also Maharashtra), and decided this was an intra-state supply. Eleven times in a row. The buyer's finance team had not caught it either, because they claim ITC on an aggregated basis and the mismatch only shows up when the 2A/2B does not match the inward register.

That is a nasty thing to find in July about April–June invoices. Credit notes, reissue, buyer's accounts team to coordinate with, the whole business. And it was not a plugin bug in the sense that something was broken — the plugin did what it was configured to do. The bug was that place of supply is not the same thing as billing address, and WooCommerce has no field for place of supply, so the plugin had to guess.

What Section 10 and Section 12 of the IGST Act actually say about place of supply

Quickly, because this is the part developers tend to skip and then regret. The IGST Act defines place of supply in two places: Section 10 for goods and Section 12 for services. They are not the same rule, and they are not symmetric with billing address — which is the mental model most WooCommerce plugins seem to be built on.

For goods under Section 10(1)(a), the general rule is the location where the movement of goods terminates for delivery to the recipient — so shipping address matters, but only as a proxy for the delivery location. Section 10(1)(b) is the bill-to-ship-to case: when goods are delivered by the supplier to one person (the ship-to) on the direction of another person (the bill-to) who is giving the instruction, the place of supply is deemed to be the principal place of business of the bill-to party — not the ship-to, and not whatever address sits in the WooCommerce order. If your buyer in Delhi tells you to ship to their warehouse in Haryana, the place of supply is Delhi, the invoice carries IGST, and the shipping address is accounting metadata — not tax metadata.

For services under Section 12, the controlling sub-section for most B2B situations is 12(2)(a): where the recipient is registered, the place of supply is the location of the recipient. "Location of the recipient" is defined elsewhere, but it collapses, in practice, to the state in which the recipient's GSTIN is registered. Not where their office is. Not where their billing contact sits. The GSTIN state. Section 12(3) carves out immovable property — if you are providing a service in relation to immovable property, the place of supply is where the property is, regardless of either party's registration, which is the kind of edge case that bites interior-design studios and architecture firms the hardest.

So: for a registered B2B recipient of services, the state code in the recipient's GSTIN is the place of supply. The billing address is irrelevant. The shipping address is irrelevant (there isn't one for services anyway, which is partly why plugins get confused). WooCommerce does not know this. The plugins mostly do not know this either.

Where WooCommerce and the common GST plugins get it wrong

Three places, mostly, and they compound.

First, the hook order. The common GST plugins hang their tax calculation off woocommerce_calculate_totals or the earlier woocommerce_cart_calculate_fees, which fire during cart calculation — before woocommerce_checkout_create_order writes the order to the database. That means the tax decision is made against whatever address state is in the session at the time of calculation, which is almost always the billing state from the checkout form. Even if you add a place-of-supply dropdown to the checkout, if you only save it in woocommerce_checkout_create_order, the tax has already been calculated. You have to either feed the value back into the cart session before calculation, or recalculate taxes on order save, or both. We do both now, after being burnt once.

Second, and this is the one that silently rewrites history: WooCommerce recalculates taxes when you edit an order in the admin and click "Recalculate." If a plugin uses the current billing address to compute place of supply, and someone in the admin updates the billing address on an old order — to fix a typo, to update a GSTIN, to correct a PIN code — the tax lines on the historical invoice get rewritten. The invoice PDF, if it is generated on the fly from order data (which most of them are), now shows a different tax split than the one that was filed in the GSTR-1 for that period. Nobody logs this. Nobody warns about it. The order note says "Order details manually updated" and that is all you get. We have seen a client's March invoice silently become an IGST invoice in August because a support agent updated the billing state during a return conversation. Reconciliation after that is a weekend's work minimum.

Third, exports. Export of services is zero-rated under Section 16 of the IGST Act, subject to the usual LUT or refund route, and the test for whether a supply is an export involves — among other things — the recipient being located outside India and payment being received in convertible foreign exchange. The plugins mostly do not run that test. They look at the billing country, if they look at anything, and some of them do not even expose a country-level toggle properly. Razorpay does not help: the buyer country ends up in different places on the payload depending on which integration you used — somewhere under notes.address in older Standard Checkout flows, somewhere under customer_details in the newer ones, and we have had to grep the actual payload more than once to find where it landed on a given project. If your tax plugin is reading from the WooCommerce billing country, and the buyer typed "India" there because the Razorpay form prefilled it — but the actual service recipient is in Singapore and paying from a Singapore account — you have just charged 18% IGST on a zero-rated export. That is money the buyer did not owe, that you now have to refund or adjust, and that was never going to show up as a refund in your GST returns because it was never valid tax in the first place.

(Aside: the worst version of this we ever saw was a client whose plugin was mapping country via a hard-coded array that did not include "AE" because it had been written in 2019 and never updated. Every UAE buyer was being tax-treated as domestic. We did not get into why that array existed.)

The checkout-field-plus-order-meta pattern we now use on every build

The fix is not another plugin — we have tried most of them. What works is stopping the tax plugin from guessing: compute the place of supply at checkout, write it to order meta before the tax calculation runs, and treat that stored value as what everything downstream reads from. The decision, not just the inputs.

The three order meta keys we add on every India build:

  • _pos_state_code — the two-digit GST state code (01–38) that represents the place of supply for this order. Not a state name. The code. Reconciliation downstream is far easier when you match on the code.
  • _pos_source — one of buyer_declared, billing, shipping, or export. This records how the place of supply was determined, which is what you want when you are back in the order six months later trying to understand why the tax split is what it is.
  • _recipient_gstin — the recipient's GSTIN if provided, stored once, validated once (checksum and state-code consistency with _pos_state_code), and never silently overwritten by a plugin's "update GSTIN" button without an order note.

At the checkout, we add an explicit place of supply dropdown that is visible for B2B orders (triggered by GSTIN entry) and for international orders. For a domestic B2C order with no GSTIN, we hide it and default to the billing state — that case is genuinely fine, and asking the buyer to pick a state code they do not understand will tank conversion. For a B2B order with a GSTIN, we default the dropdown to the state code derived from the first two digits of the GSTIN, but we let the buyer override it (because the buyer sometimes knows about a bill-to-ship-to arrangement that we do not). For international orders, we present an "Export (outside India)" option explicitly and we require the country field to be non-India and the payment currency to be non-INR before we accept it — if the buyer picked "Export" but is paying in INR from an Indian card, we fall back to domestic and log the discrepancy.

On the server, we hook into woocommerce_checkout_update_order_review to put the place-of-supply state code into the cart session before tax calculation runs, and then again into woocommerce_checkout_create_order to persist it to order meta. We also override the admin recalculate behaviour via woocommerce_order_before_calculate_totals so that recalculation reads _pos_state_code from order meta, not from the current billing address — which means if someone updates the billing address on an old order, the tax split does not change. There is an order note generated whenever _pos_state_code itself is edited, which is almost never, and when it does happen it needs to happen deliberately.

We used to compute all of this on the fly from GSTIN + billing + shipping, without storing the result as its own field. It worked, mostly. We stopped after the third reconciliation project where the business logic had drifted between the time of order and the time of reconciliation, and the recomputed value for an old order no longer matched what the invoice had actually shown. Storing the decision, not just the inputs, is the lesson. Order meta is cheap. Rebuilding six months of invoices is not.

One case we still do not have a clean answer for: a registered B2B buyer places an order under one GSTIN, we ship the goods, the invoice is issued. A week later the buyer's accounts team writes in and asks for the invoice to be reissued against a different GSTIN of the same legal entity in another state — because that is the entity that will actually use the goods and claim the ITC. The place of supply, under Section 10, technically shouldn't change (the movement terminated where it terminated), but the ITC the buyer wants certainly does, and the invoice has to carry the correct recipient GSTIN for the ITC to flow. We have handled this three or four times by issuing a credit note against the original invoice and raising a fresh invoice to the new GSTIN with the correct state treatment, which keeps the GSTR-1 clean but creates two trips through the sales register for what was commercially a single transaction. Neither the client's CA nor ours is thrilled with it. If anyone reading this has a defensible pattern that handles the GSTIN swap-after-shipment case without the credit-note dance, write in — genuinely, we want to see it.

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