ERPNext Guides

ERPNext Landed Cost & FX Gone Wrong: The Interactive Fix Guide (2026)

Exchange rates move between PO, GRN and invoice — and your ERPNext inventory silently drifts. This interactive guide shows exactly what breaks, calculates your exposure live, and gives you copy-paste automation to gate it forever.

MManojJuly 23, 202612 min read
#erpnext#landed-cost#imports#accounting
Share

When you import goods on ERPNext, the exchange rate on your Purchase Order, Goods Receipt (GRN), and Purchase Invoice are three different things — and only the GRN's rate actually values your stock. If the GRN silently inherits the PO's stale rate, your inventory is wrong the moment you hit Submit. This guide dissects the bug against ERPNext v16.28.0 source, walks the correct workflow, calculates your exposure live, and gives you a copy-paste automation gate so the problem cannot recur.

A finished-goods import that lands on a different FX rate than the PO estimate is the single most common cause of quiet inventory drift in ERPNext. I am Manoj, ERPNext and Frappe implementation lead at MithTech in Bengaluru — we run this playbook on every import-heavy client.

Try it: what one wrong rate is really costing you

Nudge the numbers to your actual last import. If the delta is bigger than your accountant's tolerance, the Fixing Historical Errors section further down tells you exactly how to close it.

Live calculator

What is one wrong exchange rate really costing you?

Booked in stock ledger
₹4,920,000
Should have been
₹5,064,000
Missing from inventory
₹144,000
FX moved +2.93% between the PO and the GRN. Your inventory is ₹144,000 under-valued — living silently in the wrong account until an LCV corrects it.

The three documents, three very different roles

The heart of the confusion is that Purchase Order, Purchase Receipt, and Purchase Invoice all carry a conversion_rate field, and users assume they mean the same thing. They don't. Tap through the workflow below to see what each stage actually does to your books.

The correct flow

Tap a stage — see what it does to your books

What happens in your books
Freezes stock valuation
Your inventory rate for life

The Purchase Order's rate is a commitment estimate

The PO's conversion_rate has no accounting impact whatsoever. No GL entries, no stock ledger entries. It exists so that base-currency totals can be shown on the PO for approval purposes, and so that downstream documents have a default to inherit.

That "default to inherit" behaviour is the root cause of most FX-related landed-cost bugs. Users assume the rate got recalculated at receipt time. It didn't — it got copied.

The Purchase Receipt's rate is what values your stock

This is the load-bearing statement of the entire post.

When you submit a GRN, ERPNext writes a Stock Ledger Entry for each item. The valuation stored is:

stock_value = qty × rate × conversion_rate   (in company / base currency)

That value is frozen into the ledger. Nothing downstream will retroactively touch it. The only mechanisms that change stock valuation after a GRN is submitted are a Landed Cost Voucher or a repost of the item's valuation.

Practical consequence: if the GRN carries the wrong exchange rate at submit time, your inventory is now wrong. Fixing the Purchase Invoice will not fix your inventory.

The Purchase Invoice's rate hits the payable and a P&L account

The PI's conversion_rate determines the rupee value of the supplier's payable (correct — you owe them at today's rate) and posts an Exchange Gain/Loss GL entry for the delta between the PR's rate and the PI's rate.

You can see this in the ERPNext source itself. In erpnext/stock/doctype/purchase_receipt/purchase_receipt.py around line 588, when GL entries are being built for a PR that has a linked PI:

if (
    exchange_rate_map[item.purchase_invoice]
    and self.conversion_rate != exchange_rate_map[item.purchase_invoice]
    and item.net_rate == net_rate_map[item.purchase_invoice_item]
):
    discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * (
        exchange_rate_map[item.purchase_invoice] - self.conversion_rate
    )
    # ... posts one leg to Stock Received But Not Billed
    # ... posts the other leg to Exchange Gain/Loss

The delta is booked to P&L against the company's default Exchange Gain/Loss account. It is not added to inventory.

Why this is accounting-correct

Under Ind AS 21 / IAS 21, a non-monetary asset (inventory) is recognised at the spot rate on the transaction date — meaning the date of receipt, not the invoice date. Subsequent FX movement on the payable is a P&L item because the payable is a monetary liability. ERPNext follows the standard. The bug is not the standard — it's that the standard depends on the GRN carrying the correct spot rate, and by default it inherits the PO's estimate instead.

The default (bad)

GRN inherits PO rate silently

  • · Stock ledger frozen at stale rate
  • · Landed cost silently understated / overstated
  • · Auditor discovers it months later
  • · Correction requires reposting
The habit (good)

Override GRN rate to receipt-day spot

  • · Inventory valued at receipt-date FX (Ind AS 21)
  • · PI books FX delta to P&L automatically
  • · No LCV cleanup needed later
  • · Gate script prevents regression

The Landed Cost Voucher and the "double stock" myth

There are two half-truths every ERPNext forum thread repeats about landed cost with imports. Let's kill them both with source.

Why the LCV button is hidden on a plain PI

Look at erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js — the button is registered conditionally:

if (frm.doc.docstatus === 1 && frm.doc.update_stock) {
    frm.add_custom_button(
        __("Landed Cost Voucher"),
        // ...
    );
}

The condition is deliberate. An LCV allocates cost across items that actually moved into stock. A PI without Update Stock posted zero Stock Ledger Entries — there's nothing to allocate against.

The correct answer is not to force the button onto the PI. It's to create the LCV against the Purchase Receipt — because that's the document that actually posted stock.

The "duplicate SLEs" claim, checked against source

The claim: "tick Update Stock on a PI while a GRN already exists and you double your stock." That is not what happens in a properly-linked flow. From erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py:

def validate_purchase_receipt_if_update_stock(self):
    if self.update_stock:
        for item in self.get("items"):
            if item.purchase_receipt:
                frappe.throw(
                    _("Stock cannot be updated for Purchase Invoice {0} "
                      "because a Purchase Receipt {1} has already been created "
                      "for this transaction. Please disable the 'Update Stock' "
                      "checkbox in the Purchase Invoice and save the invoice."),
                    title=_("Stock Update Not Allowed"),
                )

If any PI item has a purchase_receipt linked, update_stock=1 throws. If you are seeing duplicate SLEs, one of these is true:

  • The PI was created standalone (from the PO, or by hand) with Update Stock ticked, and a separate GRN was raised against the same PO. item.purchase_receipt was empty, so the guard never fired.
  • A customisation in your instance has overridden the validator.
  • The GRN and PI reference different rows and the link didn't propagate.

The fix is process, not code: always create the PI from the GRN, not from the PO, when a GRN exists.

The correct workflow, end to end

Purchase Order — an estimate, not a valuation

Enter your best guess of the FX rate. It has no downstream accounting weight. Approvals happen against a base-currency number computed from this rate; nothing else uses it.

Purchase Receipt (GRN) — override the rate

The single most important habit. Before you Submit, replace whatever ERPNext fetched from the PO with the actual spot rate on the receipt date — RBI reference rate, your bank's TT rate, or whatever your accounting policy prescribes. This is the number that lands in your Stock Ledger for life.

Landed Cost Voucher (against the GRN)

Add freight, customs duty, CHA, insurance, port handling — anything that should be capitalised into inventory. Distribute by amount or by quantity per policy. The LCV re-writes SLEs and revalues stock.

Purchase Invoice — pull from the GRN

Use Get Items From → Purchase Receipt. This wires purchase_receipt on every line so the validator can protect you. Leave Update Stock off. Enter the actual FX rate on the invoice date. The delta between GRN rate and PI rate flows to Exchange Gain/Loss automatically — where it belongs.

Inventory ends up valued at receipt-date FX. Payable ends up valued at invoice-date FX. The difference lives in P&L instead of polluting stock costs. Textbook.

Fixing historical errors

Most readers who searched their way here already have a mess. Pick your scenario — the widget below reveals the exact fix.

Which mess are you in?

Pick your scenario, get the fix

Choose a scenario above to reveal the exact fix path.

Scenario B, worked live

If your GRN is submitted with a stale rate and the stock is still on hand (or partially consumed), you can top up the valuation with a second LCV against the GRN. Adjust the inputs to your actual invoice — the numbers regenerate instantly.

Scenario B fix

LCV top-up amount, worked live

Per-unit adjustment
$10 × (8482) = ₹20
Total LCV charge
100 × ₹20 = ₹2,000
Post this as a Landed Cost Voucher
  • • Description: FX Rate Correction
  • • Amount: ₹2,000 (top-up)
  • • Account: Exchange Gain/Loss
  • • Distribute: By Amount
  • • Attach against the affected GRN

ERPNext will then:

  • Increase each item's valuation by its share of the top-up.
  • Repost outgoing SLEs so any units already sold or consumed pick up the corrected valuation, flowing the difference to COGS.
  • Skip already-closed accounting periods if your policy blocks reposting into them (see Scenario C).

Scenario C — closed period? Book a manual JV

If the period is closed and your policy forbids reposting, don't reopen the period. Book a Journal Entry in the current period instead:

  • Dr. Cost of Goods Sold (or Stock-in-Hand for units still on the shelf)
  • Cr. Exchange Gain/Loss (or the account you'd have used on the LCV)

This preserves the closed period's integrity and puts the correction in the current period where an auditor expects it. Yes, this means ERPNext's inventory still shows the old per-unit valuation — that trade-off exists in every ERP, not just this one.

Gates and automation — the permanent fix

You now know the workflow. The problem: humans forget. The solution: gate the GRN so a stale FX rate simply cannot be submitted. Pick your flavour — client script, server script, packaged app hook, or a no-code n8n workflow. All four accomplish the same guarantee.

Automation recipes

Build the gate. Never see this bug again.

Warn the user when the PO→GRN gap is > 7 days or the fetched rate is > 0.5% stale, before they submit the GRN.

client_script__gate_.js
// Custom Script → DocType: Purchase Receipt
frappe.ui.form.on('Purchase Receipt', {
  before_submit: async function(frm) {
    if (!frm.doc.conversion_rate || frm.doc.currency === 'INR') return;

    // 1. Reject if any linked PO is older than 7 days
    const po_names = [...new Set(frm.doc.items.map(i => i.purchase_order).filter(Boolean))];
    for (const po of po_names) {
      const r = await frappe.db.get_value('Purchase Order', po, 'transaction_date');
      const days = frappe.datetime.get_day_diff(frm.doc.posting_date, r.message.transaction_date);
      if (days > 7) {
        const ok = await new Promise(res =>
          frappe.confirm(
            `PO ${po} is ${days} days old. Re-check today's FX rate before submitting?`,
            () => res(true), () => res(false)
          )
        );
        if (!ok) frappe.throw('Submission cancelled — update FX rate first.');
      }
    }

    // 2. Fetch today's reference rate and compare
    const today = await frappe.db.get_value('Currency Exchange', {
      from_currency: frm.doc.currency, to_currency: 'INR',
      date: frm.doc.posting_date
    }, 'exchange_rate');

    const spot = today.message && today.message.exchange_rate;
    if (spot) {
      const drift = Math.abs(spot - frm.doc.conversion_rate) / spot * 100;
      if (drift > 0.5) {
        frappe.throw(`GRN rate ${frm.doc.conversion_rate} differs from today's ${spot} by ${drift.toFixed(2)}% — update before submit.`);
      }
    }
  }
});

What each gate actually enforces

Freshness check

Reject a GRN whose linked PO is older than 7 days without an FX-rate refresh confirmation. Removes the "just accept the default" reflex.

Drift threshold

Compare the GRN's conversion_rate against today's Currency Exchange entry. Reject on > 0.5% drift (or your policy threshold).

Reference rate pull

Nightly scheduler job that fetches RBI / exchangerate.host reference rate into Currency Exchange. Without this, your drift check has nothing to compare against.

Audit trail

on_submit snapshot of (doc.name, conversion_rate, reference_rate, drift_pct, user) into a custom FX Rate Snapshot DocType. Finance can review weekly with zero effort.

Deploy order that actually works

  1. Ship the reference-rate scheduler first — the gate is useless without a benchmark to compare against.
  2. Deploy the gate as a warning (frappe.confirm) for 2 weeks — collect the false-positive rate before enforcement.
  3. Flip to frappe.throw once the buying team has adjusted their workflow. The two-week soft launch prevents a Monday-morning revolt.
  4. Wire the audit-trail email so finance has visibility without asking.

When is any of this actually material?

Either way, the process fix costs you five seconds per GRN. There is no reason not to do it.

FAQ

Quick answer

No — you can't edit a submitted PR's exchange rate at all, and even if you could via a customisation, the Stock Ledger Entries were written at submit time and won't recalculate. The only supported mechanisms are a Landed Cost Voucher against the GRN or a repost of the item's valuation. Use the LCV calculator in this post to compute the top-up amount.

Quick answer

Because the invoice didn't move stock. ERPNext hides the button unless docstatus === 1 && update_stock === 1. This is deliberate — LCVs allocate cost across items that actually posted Stock Ledger Entries, and a PI without Update Stock posted none. Create the LCV against the linked Purchase Receipt instead.

Quick answer

Only if the PI wasn't pulled from an existing GRN. ERPNext's validate_purchase_receipt_if_update_stock throws whenever any PI item has a purchase_receipt link and update_stock=1. Duplicates happen when someone bypasses that flow by creating the PI from the PO instead of the GRN. Always use Get Items From → Purchase Receipt when a GRN exists.

Quick answer

Whatever your accounting policy defines as the spot rate on the receipt date. Most Indian SMEs we implement use the RBI reference rate for consistency and auditability; larger companies with active treasury desks use their bank's TT buying rate. The critical part is the date (receipt day, not PO day, not invoice day), not the specific source.

Quick answer

It depends on whether your Company's Ignore Accounts for Item Group / Item and period-close settings block reposting into closed periods. If they don't, an LCV will repost outgoing SLEs and hit already-closed COGS — talk to your auditor before this happens. If they do block it, follow Scenario C above: book a Journal Entry in the current period against Exchange Gain/Loss and accept that ERPNext's inventory rate stays uncorrected.

Quick answer

The check does two frappe.db.get_value calls per GRN — sub-100ms overhead. If you host on a decent VPS (any of the ones we recommend on the Hetzner vs AWS guide) it's imperceptible. The scheduler job that pulls the reference rate runs once per day and is entirely off the submit path.

Quick answer

The exact code paths cited above are from v16.28.0, but the same logic — GRN freezes stock at conversion_rate, PI posts the FX delta to Exchange Gain/Loss, LCV is the only supported correction — is present in v14 and v15 with only cosmetic differences. The gate script works unchanged across all three.

Quick answer

Then none of this applies to you — the conversion_rate collapses to 1 and there is no drift to worry about. This post is only relevant for imports invoiced in a foreign currency (USD, EUR, CNY, AED, and so on).

Closing

ERPNext's landed-cost and FX handling is, on inspection, closer to correct than the forum lore suggests. The stock ledger honours receipt-date FX; the exchange gain/loss on the invoice is booked separately; the LCV button is hidden on non-stock-moving documents on purpose; and the "double SLE" scenario is explicitly gated in code.

What breaks the flow is almost always the PO's rate silently propagating to the GRN because nobody thought to override it. Fix that one habit — and put the gate in — and 90% of the landed-cost pain disappears.

Books already tangled with historical FX drift?

We run a one-week engagement for import-heavy SMEs — audit your GRN history, calculate the exposure, deploy the automation gate, and train your buying team so this never recurs.

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 23 July 2026

Manoj

Comments

No comments yet. Start a new discussion.

Ctrl+Enter to add comment