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.
New to Landed Cost Vouchers?
This post assumes you already know what a Landed Cost Voucher is and when to use one. If not, start with the primer — ERPNext Landed Cost Voucher: how to add freight & duty to item cost — then come back here for the FX-specific edge cases.
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 for the base-currency delta between an FX rate you booked and the rate that should have applied on receipt day.
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.
Click each stage to see what it actually posts to your GL and stock ledger.
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 — and it's not just an Indian rule
Every major standards regime lands on the same split: inventory is a non-monetary asset and is measured at the spot rate on the date of the transaction (i.e. the receipt date); the payable is a monetary liability and is subsequently remeasured, with movement recognised in profit and loss.
- Ind AS 21 / IAS 21 The Effects of Changes in Foreign Exchange Rates — non-monetary items at historical rate, monetary items retranslated at closing rate with the difference to P&L (¶21–23, 28).
- US GAAP — ASC 830 (SFAS 52) Foreign Currency Matters — identical split: non-monetary at historical rate, monetary remeasured, gain/loss to earnings (¶830-10-45-17).
- UK GAAP — FRS 102 §30 — non-monetary at rate on the transaction date; monetary at closing rate; differences to P&L (§30.6, 30.9–30.10).
- CAS 19 (China) Foreign Currency Translation — identical treatment for non-monetary vs monetary items (Articles 11–12).
- AS 11 (India, pre-Ind AS legacy) — same treatment on the initial recognition and subsequent measurement split.
So the accounting answer is not controversial anywhere in the world. ERPNext follows the standard cleanly. 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.
Side-by-side of the silent-drift default and the receipt-day-override habit.
GRN inherits PO rate silently
- · Stock ledger frozen at stale rate
- · Landed cost silently understated / overstated
- · Auditor discovers it months later
- · Correction requires reposting
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_receiptwas 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
Pre-flight: check the Default Exchange Gain/Loss Account is set
Before you Submit anything, open Company master → Exchange Gain / Loss Section and confirm both Exchange Gain / Loss Account and (optionally) Unrealized Exchange Gain / Loss Account are populated. On fresh installs these are sometimes blank, and both the PI's auto-post and the manual LCV correction below will fail with an opaque error about a missing default account. Typical name for the mapped account: "Exchange Gain/Loss" (or "Foreign Exchange Gain/Loss"), under Indirect Expenses. This is the account ERPNext's purchase_receipt.py calls get_company_default("exchange_gain_loss_account") for, so a blank field literally breaks the flow.
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.
What if stores does GRN and accounts owns FX?
The workflow above assumes the person receiving the goods can also set an authoritative exchange rate. In a lot of SMEs that's not how the org runs — the warehouse hand who unlocks the container has no business (and no authority) deciding whether today's rate is RBI reference or bank TT. That's an accounts-team call. Which is fine. Here's the split that actually works.
Stores books the GRN as-is
Stores submits the Purchase Receipt at whatever conversion_rate propagated from the PO. No FX judgement on their side. Their job is to reconcile physical qty and quality against the PO, not to shadow the treasury desk. The GRN posts stock at a rate that everyone acknowledges is provisional.
Accounts works from a saved 'GRNs Awaiting FX Correction' report
Build a saved Report Builder view — or a small custom Query Report — filtered to Purchase Receipt where docstatus=1 and currency != 'INR' and posting_date in current period, and no linked FX-correction LCV yet. This is the accounts team's daily worklist. One view, one filter, no chasing.
Accounts opens each GRN, checks the true receipt-date rate
Against the company's stated policy (RBI reference on receipt date, bank's TT buying rate, or whatever the accounting manual says). If the delta between the GRN's rate and the policy rate is material by the company's threshold (see When is any of this actually material? below), accounts creates a Landed Cost Voucher against the GRN with a single "FX Rate Correction" charge line. If it isn't material, they mark the GRN reviewed and move on.
Accounts books the Purchase Invoice separately
Pulled from the GRN via Get Items From, Update Stock OFF, at the actual invoice-date FX rate. Any residual FX difference between receipt-date and invoice-date auto-posts to Exchange Gain/Loss — and that residual is a genuine P&L item that the standards want in P&L (see the standards box above). Accounts doesn't need to touch it.
At high import volumes you can partially automate step 3: a server script pre-fills a draft LCV given a rate-lookup source (RBI SDMX feed, exchangerate.host, your bank's daily rate email), and accounts only reviews and submits. See the Automation Recipes section below — the Server Script (Auditor) recipe is close to what you'd extend for this.
Two things this split gets right that a naive "everyone always overrides on the GRN" model doesn't:
- Segregation of duties is preserved. Stores can't move accounting numbers around; accounts can't book stock without a GRN. Both keep their scope.
- The audit trail is clean. Every FX correction is its own dated LCV with a clear description and, ideally, a reference-rate source attached — instead of a stores-team hand-edit of the GRN's
conversion_rateat 6pm on a Friday.
Fixing historical errors
Most readers who searched their way here already have a mess. Pick your scenario — the widget below reveals the exact fix.
Pick your situation (Scenario A, B, C, or D) — get the specific ERPNext fix path.
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.
Enter qty, unit price, stale GRN rate and correct spot rate — get the exact 'FX Rate Correction' amount to post as an LCV.
- • 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).
Which account head do I post the LCV to — and why?
This is the question that trips up most accountants when they run this procedure for the first time, and the answer matters more than it looks. The current default recommendation and the reasoning:
Recommended default: use the company's existing Default Exchange Gain / Loss Account — the one you already confirmed was set in the pre-flight check above (Company master → Exchange Gain / Loss Section → Exchange Gain / Loss Account, typically an Indirect Expenses head named "Exchange Gain/Loss" or "Foreign Exchange Gain/Loss"). No new account head required.
Why this specific account, and not "some FX-ish expense account we made up": this is the load-bearing insight that the rest of this section rests on. ERPNext already auto-posts an FX difference entry to this exact account when the PI's conversion_rate differs from the GRN's — that's the purchase_receipt.py:588 code path we cite earlier, whose debit leg is literally account=self.get_company_default("exchange_gain_loss_account"). If your manual LCV correction uses the same account, the two entries offset each other. Net P&L is clean, stock ends up at the true receipt-date rate, and the residual balance in Exchange Gain/Loss reflects only the genuine invoice-date-vs-receipt-date FX movement — which is exactly what Ind AS 21 / IAS 21 / ASC 830 (and every other standard covered above) want sitting in P&L.
The offsetting mechanics, walked through numerically
Take the worked example above — 100 units, USD 10 unit price, GRN submitted at ₹82, correct receipt-date rate ₹84. Assume the invoice arrives later at ₹84 as well (so the invoice-date and receipt-date rates match; we're isolating just the GRN-was-stale problem). Three postings happen, in order:
1. GRN (submitted with the stale ₹82 rate)
Dr Stock in Hand 82,000
Cr Stock Received But Not Billed (SRBNB) 82,000
2. Purchase Invoice at ₹84 — ERPNext auto-posts the FX discrepancy
Dr Stock Received But Not Billed (SRBNB) 82,000
Dr Exchange Gain/Loss 2,000
Cr Supplier (Payable) 84,000
The Exchange Gain/Loss debit here is the exact entry generated by the purchase_receipt.py:588 code path — ERPNext books the FX delta itself, without asking. It hits P&L, not stock. Correct per the standards.
3. LCV correction (this post's fix, posted to the same account)
Dr Stock in Hand 2,000
Cr Exchange Gain/Loss 2,000
Net effect across all three entries:
| Account | Dr | Cr | Net |
|---|---|---|---|
| Stock in Hand | 84,000 | — | 84,000 Dr ✓ |
| Stock Received But Not Billed | 82,000 | 82,000 | 0 ✓ |
| Supplier / Payable | — | 84,000 | 84,000 Cr ✓ |
| Exchange Gain/Loss | 2,000 | 2,000 | 0 ✓ |
That is identical to what a correctly-booked GRN at ₹84 would have produced from the start. Which is what makes the whole procedure defensible in front of an auditor: the correction plus ERPNext's own auto-post arrive at the same ledger state a "did it right the first time" flow would have. The Exchange Gain/Loss account carries no artificial noise; it reflects only real receipt-date-vs-invoice-date FX movement (in this example, none — so it nets to zero).
If the invoice-date rate had been ₹84.60 instead of ₹84, the story would be almost the same — except a genuine ₹60/unit × 100 = ₹6,000 residual would sit in Exchange Gain/Loss at the end, representing real remeasurement of the monetary payable. That's a real P&L item and belongs there.
When to create a dedicated account instead
Optional, not required. Consider a separate head — suggested name "Exchange Rate Adjustment — Imports" under Indirect Expenses — if:
- Your auditor explicitly wants GRN-rate corrections separated from real remeasurement gain/loss (some do, especially in first-year Ind AS conversions).
- Your import volume is high enough that noise from these corrections would swamp the Exchange Gain/Loss account and obscure real FX movement.
- Management wants a management-accounting KPI on "cost of stale PO rates" — a running P&L number that quantifies process slippage in the buying team.
Same accounting treatment, same offsetting logic — just segregated for reporting. If you go this route, you'll no longer get the automatic self-cancellation with ERPNext's PI auto-post; you accept that trade-off in exchange for the visibility.
Do not post the LCV correction to any of these
Every one of these gets picked wrong at least twice per year in the wild. In no particular order:
- Not COGS. The correction is a valuation adjustment on stock still on hand, not a consumption event. COGS is only touched later, if ERPNext reposts outgoing SLEs for units that have already been sold.
- Not a stock adjustment / Stock-in-Hand account. Debiting a stock account and crediting Stock-in-Hand double-counts on the balance sheet.
- Not the supplier / creditor account. The supplier's payable was correctly booked at the invoice-date rate on the PI; touching it retrospectively breaks the payables sub-ledger.
- Not bank charges. Real FX remeasurement is not a bank charge, and neither is a GRN correction. Bank charges are the fee your bank actually took, which is a separate PI or Journal Entry.
The rest of the FX story — payment, advances, period-end
Once you have the GRN → LCV → PI trio behaving, three more FX events matter for an import. The post has been quietly assuming they don't exist. They do.
Payment to the supplier weeks later — Payment Entry posts more FX
You wired ₹84,000 to the supplier's bank on a day when USD/INR was ₹84.90. ERPNext's Payment Entry compares your bank's actual outflow to the payable it's settling (which sits at the PI's ₹84 rate) and posts the difference to the Default Exchange Gain/Loss account automatically. Nothing to do — but know that this is the third FX posting on the same shipment:
Dr Supplier (Payable) at PI rate 84,000
Dr Exchange Gain/Loss 900 # USD 1000 × (84.90 − 84.00)
Cr Bank Account 84,900
If your books show a growing balance in Exchange Gain/Loss over the year, this is where most of it comes from — settling monetary liabilities at rates different from when they were recognised. That's real remeasurement gain/loss and every standard we cited above wants it in P&L.
Supplier advance in FX — the landmine most people miss
If you pre-paid part of the invoice as an advance in USD (say USD 3,000 wired at ₹83), the Payment Entry booked that portion at ₹83. When the PI arrives later at ₹84 and you allocate the advance against it, ERPNext posts an FX difference only on the advance portion (USD 3,000 × (₹84 − ₹83) = ₹3,000) to Exchange Gain/Loss — and settles the rest at ₹84. What you should NOT do is manually override the advance's rate to match the PI, which is a surprisingly common mistake that breaks the payable sub-ledger and hides real FX movement. Let ERPNext handle it.
Period-end — Exchange Rate Revaluation on open FX payables
Ind AS 21, IAS 21, ASC 830 and FRS 102 all require you to remeasure monetary items denominated in a foreign currency at the closing rate on the balance sheet date, with the movement to P&L. In ERPNext that job is a dedicated DocType: Exchange Rate Revaluation (erpnext/accounts/doctype/exchange_rate_revaluation). At period end you enter the revaluation date, pick the accounts to revalue (typically your foreign-currency Creditors and Debtors accounts), ERPNext computes the delta against today's Currency Exchange rate for each open balance, and generates a Journal Entry booking the movement to Exchange Gain/Loss.
Two things to know:
- It's a period-end job, not per-transaction. Run it as part of your month-end / quarter-end close checklist.
- Unrealized vs realised. The revaluation posts to the unrealised Exchange Gain/Loss account (Company master →
Unrealized Exchange Gain / Loss Account) if that field is populated; otherwise it falls back to the realised one. Set it if your auditor wants the split — it's a common Ind AS 21 disclosure request.
Landed-cost distribution — by amount, by qty, or by weight?
The correct workflow section says "distribute by amount or by quantity per policy." Which one is right depends on the charge type. A quick rule of thumb every buying team should have written down:
ERPNext v16 supports Distribute Charges Based On = Amount or Qty out of the box. For weight-based distribution you'll need a custom script on the LCV or a helper server script; ping us if you want the recipe. Getting freight distributed by amount instead of weight is a common source of per-unit landed cost being wrong on high-weight low-value SKUs (steel, chemicals, packaging).
Adjacent but distinct — reverse-charge IGST on imports
For completeness, so nobody conflates this with the FX story: on imports of goods you pay IGST at customs under the Customs Tariff Act, and you claim it back as ITC on the strength of the Bill of Entry — separately from the supplier invoice. That's a compliance flow, not an FX flow, and neither the LCV nor Exchange Gain/Loss touches it. If you're seeing IGST amounts landing in the LCV correction column, someone has crossed the wires — cover it in a Purchase Invoice / Journal Entry against your Import IGST account instead. We have a separate post on that; ask if you can't find it.
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.
Four copy-paste automations that prevent a stale-rate GRN from ever being submitted again: client script, server script, hooks.py, and an n8n workflow.
Warn the user when the PO→GRN gap is > 7 days or the fetched rate is > 0.5% stale, before they submit the GRN.
// 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
- Ship the reference-rate scheduler first — the gate is useless without a benchmark to compare against.
- Deploy the gate as a warning (frappe.confirm) for 2 weeks — collect the false-positive rate before enforcement.
- Flip to
frappe.throwonce the buying team has adjusted their workflow. The two-week soft launch prevents a Monday-morning revolt. - 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.
A concrete threshold you can lift into your SOP
If your accounts team is going to run the "GRNs Awaiting FX Correction" report daily (see the org-workflow section above), they need a written policy on when to raise an LCV and when to let a small delta ride. Below is a defensible starting point — treat these numbers as a template your CA / auditor signs off on, not as gospel:
FX Correction Policy (draft — for auditor review): Raise a "FX Rate Correction" Landed Cost Voucher against a submitted Purchase Receipt when either (a) the FX rate has moved more than 0.5% between the PO date and the GRN posting date, or (b) the absolute FX-related delta on the consignment exceeds ₹5,000 (base-currency, computed as
qty × rate × |policy_rate − grn_conversion_rate|, summed across items). Below both thresholds, do not raise a correction — the residual sits in Exchange Gain/Loss via the PI's auto-post, and its P&L noise is immaterial by the company's materiality benchmark. All corrections are booked against the company's Default Exchange Gain / Loss Account under Indirect Expenses. Rate source: RBI reference rate on GRN posting date; fall back to State Bank of India TT buying rate if RBI has not yet published.
Three things to notice about this being written down rather than left to individual judgement:
- Auditors love a policy, hate ad-hoc. A written threshold — even a conservative one — with a documented rate source is dramatically easier to defend at year-end than "we corrected it when it looked material."
- The exact numbers should match your company's materiality. ₹5,000 is a placeholder that fits many mid-sized Indian SMEs; a company with a ₹500 crore turnover should be running a much higher threshold. Anchor it to your existing materiality benchmark (often 0.5% of PBT or 1% of revenue) rather than an internet blog post's number.
- 0.5% FX move is a genuinely low threshold. USD/INR routinely moves that much in a single week. Expect your accounts team to raise corrections on 20–40% of import GRNs the first month, dropping to under 10% once the buying team learns to align PO cutting with likely-shipment windows.
FAQ
+Does editing the exchange rate on a submitted Purchase Receipt fix the stock ledger?
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.
+Why can't I see the 'Create Landed Cost Voucher' button on my Purchase Invoice?
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.
+If I tick Update Stock on the PI, will I get duplicate stock entries?
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.
+Which rate should I use on the GRN — RBI, TT, or something else?
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.
+How does the LCV FX top-up interact with a closed accounting period?
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.
+Will the gate script slow down GRN submission?
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.
+Does this apply to ERPNext v14 and v15, or just v16?
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.
+What if we invoice in INR to the supplier and there's no FX at all?
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.