ERPNext GST India

Bill-to-Overseas / Ship-to-India in ERPNext: Getting the GST Classification Right (2026)

When you invoice an overseas customer for goods you deliver inside India, ERPNext used to default to Export — and your GSTR-3B silently under-reported domestic supply. Here is the exact fix, grounded in v16 india_compliance source, with interactive scenarios and a copy-paste automation gate.

MManojJuly 24, 202613 min read
#erpnext#gst#india#tax
Share

You raise a Sales Invoice on ERPNext for a US customer. You physically ship the goods to their sister-concern in Chennai, not to a US port. ERPNext helpfully picks Overseas as the GST Category, defaults the Place of Supply to 96-Other Countries, and the invoice drops into your GSTR-3B as a zero-rated export. It isn’t. It is a domestic supply that owes IGST — and your 3B just quietly under-reported taxable turnover.

This is one of the most common misclassifications we clean up on inherited ERPNext instances in Bengaluru — and it was a real, tracked bug in v14 (frappe/erpnext#35479). I am Manoj, ERPNext and Frappe implementation lead at MithTech — this post grounds every claim against the current india_compliance source in v16.

Try it: is this invoice really an export?

Pick the customer, supply type, and physical destination. The widget names the correct classification and which GST accounts should apply — following the same branching that india_compliance/gst_india/utils/__init__.py uses.

CalculatorGST Classifierlink

Country + place of supply + goods/services → the exact ERPNext classification and which GST accounts apply.

Interactive classifier

Is this invoice really an export?

POS is not the customer’s billing state — it is the state where the goods are delivered (IGST Act §10(1)(b)) or, for services, the §13 test.
Classification
Domestic supply to an overseas customer
GST Category
Overseas (customer master) — POS is Indian state
Tax accounts
IGST (inter-state)

IGST §10(1)(b): bill-to ≠ ship-to → POS = ship-to state. Not an export. This is where ERPNext v14 used to misclassify. In current india_compliance, get_overseas_place_of_supply() correctly returns the Indian state when the shipping address is in India.

Two provisions do all the work.

For goods — IGST Act §10(1)(a) and (b). §10(1)(a) is the general rule: place of supply is where the movement of goods terminates for delivery. §10(1)(b) is the bill-to / ship-to override: "where the goods are delivered by the supplier to a recipient or any other person on the direction of a third person… it shall be deemed that the said third person has received the goods and the place of supply of such goods shall be the principal place of business of such third person." Read plainly: if the billing customer directs delivery to a different location, POS = that location.

For services — IGST Act §13. When the supplier or recipient is outside India, §13 sets POS through a cascade — §13(2) general rule, §13(3) services in relation to goods physically performed in India, §13(5) events, and so on. Export of services additionally requires payment in convertible foreign exchange under §2(6) of the IGST Act.

Neither section mentions the customer’s billing address. The billing address is a masterdata property; the place of supply is a fact about the transaction.

Not legal advice

This post explains how ERPNext applies these rules in code. It is not tax advice. For borderline cases — SEZ Developer vs SEZ Unit, LUT eligibility, mid-year GSTR revisions — verify with your CA and check the current notification on cbic-gst.gov.in.

What ERPNext actually does — pinpoint the classification function

Look at india_compliance/gst_india/utils/__init__.py. Two functions carry the whole burden.

get_place_of_supply(party_details, doctype) dispatches on GST Category. For sales, when the customer is Overseas it delegates to a specialised function:

# india_compliance/gst_india/utils/__init__.py (v16.x)
def get_place_of_supply(party_details, doctype):
    if doctype in SALES_DOCTYPES or doctype == "Payment Entry":
        # for exports, Place of Supply is set using GST category in absence of GSTIN
        if party_details.gst_category == "Overseas":
            return get_overseas_place_of_supply(party_details)
        # ... regular POS via GSTIN state / address

get_overseas_place_of_supply(party_details) is where the bill-to / ship-to logic lives. It defaults POS to 96-Other Countries, then — if a Shipping Address is set and its country is India with a valid gst_state_number — overrides POS to the Indian state:

# india_compliance/gst_india/utils/__init__.py (v16.x)
def get_overseas_place_of_supply(party_details):
    """
    As per definition the of Export, material should be shipped to a place outside India.
    Where the material is shipped in India, Place of Supply should be the location where
    the material is shipped to.
    """
    place_of_supply = "96-Other Countries"

    if not party_details.shipping_address_name:
        return place_of_supply

    shipping_address_details = frappe.get_value(
        "Address",
        party_details.shipping_address_name,
        ("country", "gst_state_number", "gst_state"),
        as_dict=True,
    )

    if shipping_address_details.country == "India" and shipping_address_details.gst_state_number:
        place_of_supply = f"{shipping_address_details.gst_state_number}-{shipping_address_details.gst_state}"

    return place_of_supply

Read the guard carefully. Two things must both be true for POS to switch to an Indian state:

  1. shipping_address_name must be populated on the Sales Invoice.
  2. The Address record must have country = "India" and a non-empty gst_state_number.

If either is missing — no Shipping Address, or an Indian address whose state field wasn’t filled — POS silently stays at 96-Other Countries and the invoice ships as an export. That is the trap.

The downstream "is this a foreign transaction?" test in the same file makes the consequence explicit:

def is_foreign_transaction(gst_category, place_of_supply):
    return gst_category == "Overseas" and place_of_supply == "96-Other Countries"

Both conditions must hold. Fix POS to an Indian state and the invoice stops behaving as an export everywhere downstream — GST accounts, GSTR-1 table selection, e-invoice SUPTYP field — because they all funnel through this predicate.

Why SEZ is different

SEZ supplies are treated as inter-state under §7(5) of the IGST Act regardless of geography. In india_compliance, is_overseas_transaction() short-circuits on gst_category == "SEZ" and returns True — so an SEZ supply always attracts IGST, even when the SEZ unit is in your own state.

Six real bill-to / ship-to scenarios

Tap through each — the widget shows the exact GST Category, POS, and tax accounts you should set on the Sales Invoice.

InteractiveBill-to / Ship-to Scenario Deciderlink

Six real-world bill-to / ship-to combinations — tap any to see the correct ERPNext setup.

Real-world scenarios

Six bill-to / ship-to cases — get the setup right

Scenario B
US buyer, goods shipped to their Indian sister-concern in Chennai
GST CategoryOverseas
Place of Supply33-Tamil Nadu (from Shipping Address)
Customer GSTIN(empty)
Shipping AddressChennai address (India)
TaxIGST (inter-state — company in Karnataka, POS Tamil Nadu)

The bug case. IGST §10(1)(b): POS = ship-to. Not an export. get_overseas_place_of_supply() picks the Indian state automatically when shipping address is in India.

The setup that gets it right — field by field

1

Customer master — set GST Category first

For an overseas company: GST Category = Overseas, Customer GSTIN empty, country of billing address ≠ India. guess_gst_category() in the same utils file will pick this automatically if the billing address country is set correctly.

2

Address records — split billing and shipping

Create the billing address (overseas) and the shipping address (Indian, with gst_state and gst_state_number populated) as two separate Address records. The Sales Invoice needs to reference both — do NOT paste an Indian ship-to into the Notes field and hope it flows.

3

Sales Invoice — verify Shipping Address is set

get_overseas_place_of_supply() returns 96-Other Countries the moment shipping_address_name is empty. This is the single most common failure mode. Confirm the field is populated before you compute taxes.

4

Trigger a POS recompute

Change of Shipping Address triggers set_place_of_supply client-side. If POS still reads 96-Other Countries, open the Shipping Address record and confirm both country = "India" and gst_state_number are filled — the guard needs both.

5

Tax template — pick IGST or CGST+SGST by POS state

POS state ≠ company state → IGST. POS state = company state → CGST + SGST. Do NOT use a zero-rated export template just because the customer master says Overseas.

6

Currency and rate — separate concern

An overseas customer typically bills in USD/EUR. GST amount is computed in company currency. Verify the FX rate against your policy — see our Landed Cost + FX guide for the mechanics.

InteractiveSales Invoice Field Checklistlink

Every ERPNext Sales Invoice field that must be set correctly, in one tick-as-you-go list.

Pre-submit checklist

Every field that must be right on the Sales Invoice

0 / 10

Nothing here is saved — this is a checklist for your current thought, not your ERPNext record.

GSTR reporting downstream — where the invoice lands

Once the classification is right, everything downstream falls into place. If it’s wrong, the invoice lands in the wrong return section — which is what your CA sees at filing time.

InteractiveGSTR Section Mapperlink

Flow-arrow visualisation from your invoice classification to the GSTR-1 table, GSTR-3B section, and eInvoice IRN slot it lands in.

Downstream mapping

Where does this invoice actually land?

Your classification
Overseas customer, delivered inside India
GSTR-3B3.1(a) Outward taxable (other than zero-rated)
GSTR-1B2C(L) or B2C(S) depending on invoice value + POS state
eInvoice IRNSUPTYP: B2C (no IRN required if turnover threshold not breached; otherwise B2C QR)

Section numbers reflect the return format in effect as of 2026. Verify against the current GSTN utility before filing — the schema updates most quarters.

The line-item breakdown by return section is defined across india_compliance/gst_india/utils/gstr_1/ and gstr3b/. The classification is inherited from the Sales Invoice’s GST Category + POS — not recomputed at filing time. So fixing the classification after the invoice is submitted requires an amendment or a credit note; you cannot just "reclassify" a filed row.

Fixing invoices that were classified wrong

Two paths, and which one you pick depends on whether the invoice has flowed into a filed return yet.

Not filed yet, same period: Cancel and amend. Open the Sales Invoice, hit Amend, fix the GST Category / Shipping Address / POS, and re-submit. The new invoice takes a -1 suffix; the cancelled one shows in your audit trail as Cancelled.

Already in a filed GSTR-3B: Do not touch the historical invoice. Raise a Credit Note against the wrong-classification invoice in the current period, then raise a fresh Sales Invoice with the correct classification. Both will land in the current return; the credit note offsets the earlier overstatement of exports (which was an understatement of taxable supply), and the fresh invoice books the taxable supply where it should have gone.

Credit-note practice

Set the Credit Note’s reason clearly ("GST classification correction — mistakenly filed as export, actual delivery inside India") — GST auditors read these. Keep the underlying documentation (LR, e-way bill copy, delivery challan) linked to the invoice so the correction is defensible.

Automation gate — the permanent fix

Humans forget to set Shipping Address. The gate is a before_save client script that warns whenever Customer country ≠ India but Shipping Address is in India — before the user submits.

// Custom Script → DocType: Sales Invoice
frappe.ui.form.on('Sales Invoice', {
  async before_save(frm) {
    if (!frm.doc.customer) return;

    // Pull the two country fields we need to compare
    const customer_country = await frappe.db.get_value(
      'Address', frm.doc.customer_address, 'country'
    );
    const shipping_country = frm.doc.shipping_address_name
      ? await frappe.db.get_value(
          'Address', frm.doc.shipping_address_name, 'country'
        )
      : { message: { country: null } };

    const bill_c = customer_country.message?.country;
    const ship_c = shipping_country.message?.country;

    // The classic bill-to-overseas / ship-to-India case
    if (bill_c && bill_c !== 'India' && ship_c === 'India') {
      // Confirm GST Category isn't stuck on Overseas + POS 96
      if (
        frm.doc.gst_category === 'Overseas' &&
        (frm.doc.place_of_supply || '').startsWith('96-')
      ) {
        frappe.msgprint({
          title: __('GST classification check'),
          indicator: 'orange',
          message: __(
            'Customer billing country is <b>{0}</b> but Shipping Address is <b>India</b>. ' +
            'This is a domestic supply — Place of Supply should be an Indian state, ' +
            'not 96-Other Countries. Open the Shipping Address and confirm gst_state is set, ' +
            'then trigger a POS recompute before submitting.',
            [bill_c]
          )
        });
      }
    }

    // SEZ sanity — POS must not be 96 for SEZ either
    if (
      frm.doc.gst_category === 'SEZ' &&
      (frm.doc.place_of_supply || '').startsWith('96-')
    ) {
      frappe.throw(__(
        'SEZ supplies are always inter-state IGST — Place of Supply must be an Indian state.'
      ));
    }
  }
});

Deploy it as a warning (msgprint) for two weeks, watch the false-positive rate, then flip the classification failure to frappe.throw once the accounts team has adjusted. Same soft-launch pattern we recommend on every gate.

Judgement calls

Two things worth flagging where the source doesn’t answer the question by itself:

  • The v14 bug in frappe/erpnext#35479 was that ERPNext core (before india_compliance split out into its own app) didn’t always route bill-to-India / ship-to-India cases through the shipping-address override. The current v16 flow — get_overseas_place_of_supply reading shipping_address_name.country — is the fix. If you’re on v13 or v14, upgrade india_compliance before relying on this behaviour.
  • Export of services under §13 has enough moving pieces (POS, forex-payment, distinct-persons rule) that the classifier above only flags it as "verify with your CA". We deliberately do not automate the classification because the safe default is a taxable domestic supply — the export benefit is opt-in and evidenced.

FAQ

+If my customer master says Overseas but I ship inside India, do I still need to invoice in the customer's currency?

No — those are separate concerns. GST Category drives tax classification; the invoice currency drives payable/receivable valuation. You can invoice in INR for an overseas customer if your commercial contract allows, and you can invoice in USD for an Indian shipment if your contract does. The GST accounts still follow POS.

+Will an SEZ supply to a unit in my own state attract CGST + SGST?

No. is_overseas_transaction() short-circuits on gst_category == "SEZ" and returns True regardless of state, so the transaction is inter-state per §7(5) of the IGST Act — always IGST, either zero-rated under LUT or with-payment for the refund route. The e-Waybill code even forces the billing-state override to 96 - Other Countries for SEZ per e_waybill.py.

+What if I set GST Category to Overseas but forget to set the Shipping Address?

get_overseas_place_of_supply() returns 96-Other Countries as its default when shipping_address_name is empty. The invoice will be classified as an export and land in GSTR-3B table 3.1(b). This is the single most common failure mode of the bug we describe in this post — always set the Shipping Address for overseas customers.

+Does the classification change if the customer has an Indian GSTIN as well as an overseas billing address?

Yes — guess_gst_category() returns based on GSTIN presence and format, and an Indian GSTIN in Registered Regular format overrides the Overseas guess. The customer then behaves as a regular Indian party for classification. Most companies deliberately do NOT put an Indian GSTIN on an overseas customer master; if the same legal entity has both, model it as two Customer records.

+How do I fix an invoice that already went into a filed GSTR-3B under the wrong category?

Don't edit the historical invoice. Raise a Credit Note against it in the current return period, then raise a fresh Sales Invoice with the correct GST Category, Shipping Address, and Place of Supply. Both flow into the current 3B — the CN offsets the misclassified line and the fresh invoice books the correct one. Attach a note on the CN explaining the reason so it's defensible on audit.

+Is a Delivery Challan enough as ship-to evidence, or do I need an e-way bill?

For inter-state and >₹50k value intra-state movements you need an e-way bill regardless — the Delivery Challan is not a substitute. The e-way bill’s Ship-to fields are what auditors match against your invoice’s Shipping Address, so keeping them consistent is what makes the bill-to / ship-to case defensible.

+Does this same logic apply to Purchase Invoices — bill-from-overseas / ship-from-India?

Related but not identical. is_import_of_goods() gates on IMPORT_GST_CATEGORIES = ("Overseas", "SEZ") plus are_goods_supplied(). For purchases the POS logic uses company GSTIN rather than shipping address by default. If you buy from an overseas supplier who ships from within India (a high-seas-sale variant), model it explicitly — the automatic classification will not catch every case.

+Which return section does an SEZ supply land in — 3.1(a) or 3.1(b)?

3.1(b) — zero-rated outward taxable supplies — because SEZ is treated as zero-rated under §16 of the IGST Act. It reports in GSTR-1 Table 6B (SEZ supplies), distinct from Table 6A (exports). The e-invoice SUPTYP will be SEZWP or SEZWOP based on the Is Export With GST flag on the Sales Invoice.

Closing

The bill-to / ship-to case is one of the highest-impact classification errors we see on inherited ERPNext instances — and one of the easiest to prevent. Current india_compliance does the right thing when the Shipping Address is set correctly; the failure mode is almost always a missing Shipping Address or an Indian address with an empty gst_state.

Fix that one habit, drop the gate script into your instance, and this class of misclassification stops appearing in your GSTR-3B forever.

Inherited an ERPNext instance with tangled GST classification?

We run a one-week GST classification audit for import/export-heavy SMEs — reconcile your last four GSTR-3Bs, list every misclassified invoice, deploy the gate, and train your accounts team so this stops recurring.

Free · By email

Get practical ERPNext & automation guides

New implementation guides, cost breakdowns and open-source tips for Indian businesses — occasionally, straight to your inbox. No spam.

M

Written by

Manoj

Founder of Mith Tech, an open-source ERP & automation studio. Hands-on ERPNext/Frappe implementation across multi-branch, multi-warehouse Indian operations — GST/TDS/PT compliance, branch-level permissions, and custom Frappe apps that give management real-time visibility.

Keep reading

See what this looks like for your business

Book a free 30-minute audit. We'll map your workflows, find where time and money leak, and design an open-source stack you actually own — no per-user licence fees.

Book a consultation
0 0
Published on 24 July 2026

Manoj

Comments

No comments yet. Start a new discussion.

Ctrl+Enter to add comment