When you import goods on ERPNext, the exchange rates on your Purchase Order, Goods Receipt (GRN) and Purchase Invoice are three different things, and only the GRN's rate values your stock. If the GRN quietly inherits the PO's stale rate, your inventory is wrong the moment you submit. This guide walks through the behaviour against the ERPNext v16.28.0 source, the correct workflow, a live calculation of your exposure, and an automation check so it doesn't recur.
An import that lands at a different exchange rate from the PO estimate is a common cause of quiet inventory drift in ERPNext. I lead ERPNext and Frappe programmes at MithTech, a team of Frappe specialists in Bengaluru, and we use this approach with clients who import.
Try it: what is one wrong rate costing you?
Set the numbers to your last import. If the delta is bigger than your accountant's tolerance, the section on fixing historical errors further down shows 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.
Three documents, three 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 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. 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 was recalculated at receipt time. It wasn't: it was copied.
The Purchase Receipt's rate is what values your stock
This is the central point of the 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 that day'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.
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
Two half-truths come up again and again in forum threads about landed cost on imports. Here is what the source says about each.
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 no Stock Ledger Entries, so there is nothing to allocate against.
The correct answer is not to force the button onto the PI. It is to create the LCV against the Purchase Receipt, because that is the document that 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.
What is 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 most important habit. Before you submit, replace whatever ERPNext fetched from the PO with the actual spot rate on the receipt date: the FBIL reference rate, your bank's TT rate, or whatever your accounting policy sets. This is the number that lands in your Stock Ledger for life.
Landed Cost Voucher (against the GRN)
Add freight, customs duty, CHA, insurance and port handling: anything that should be part of inventory cost. 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 difference between the GRN rate and the PI rate goes 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 sits in P&L instead of in stock cost.
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 many companies that isn't how it works: the person who opens the container shouldn't be deciding which reference rate applies. That is the accounts team's call. Here is a split that 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 check quantity and quality against the PO, not to set exchange rates. 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 (the FBIL reference rate on the receipt date, the 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 real 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 from a rate source (the FBIL reference rate, a rate API, or your bank's daily rate email), and accounts only reviews and submits. See the automation 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 hand-edit of the GRN's
conversion_rateby whoever was on shift.
How do you fix historical errors?
If you are here because the books are already wrong, pick your scenario; the widget below shows the fix.
Pick your situation (Scenario A, B, C, or D) — get the specific ERPNext 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. Set the inputs to your invoice and the numbers update.
Enter qty, unit price, stale GRN rate and correct spot rate — get the exact 'FX Rate Correction' amount to post as an LCV.
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 do I post the LCV to, and why?
This trips up accountants the first time they run the procedure, and it matters more than it looks.
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 account and not another FX expense account: ERPNext already auto-posts an FX difference entry to this account when the PI's conversion_rate differs from the GRN's. That is the purchase_receipt.py:588 code path cited earlier, whose debit leg is 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 real movement between receipt date and invoice date, which is what Ind AS 21, IAS 21 and ASC 830 want 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 entry made by the purchase_receipt.py:588 code path: ERPNext books the FX difference itself. It hits P&L, not stock, as the standards want.
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. That is what makes the procedure defensible to an auditor: the correction plus ERPNext's own auto-post reach the same ledger state as a flow done right first time. The Exchange Gain/Loss account carries only the real movement between receipt and invoice dates (in this example none, so it nets to zero).
If the invoice-date rate had been ₹84.60 instead of ₹84, the result would be almost the same, except that a real residual of USD 1,000 × ₹0.60 = ₹600 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 account (for example "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 figure for the "cost of stale PO rates": a running P&L number that quantifies process slippage in the buying team.
Same accounting treatment, kept apart 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 in exchange for the visibility.
Do not post the LCV correction to any of these
These are the usual wrong choices:
- 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 Stock Adjustment. The LCV's debit already goes to stock; crediting Stock Adjustment mixes an exchange-rate correction with count differences and hides its cause.
- 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 the GRN, LCV and PI behave, three more FX events matter for an import.
Payment to the supplier weeks later: the Payment Entry posts more FX
You pay the supplier the USD 1,000 on a day when USD/INR is ₹84.90, so ₹84,900 leaves your bank. 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 usually where it comes from: settling liabilities at rates different from when they were recognised. That is real remeasurement gain or loss, and the standards above want it in P&L.
Supplier advance in FX: the one 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. Don't override the advance's rate by hand to match the PI: 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.
Landed-cost distribution: by amount, by qty, or by weight?
The workflow above says "distribute by amount or by quantity per policy". Which is right depends on the charge. A rule of thumb worth writing down:
| Ocean / air freight | Net weight (or qty if items are similar) | Freight is priced by weight, not value |
| Insurance premium | Amount (item value) | Insurance premium scales with declared value |
| Customs BCD (Basic Customs Duty) | Amount, applied per HSN group | Rate is per HSN; distribute inside each HSN cluster |
| CHA / clearing agent | Amount | Fee tracks value moved, close enough |
| Inland transport | Weight or qty | Trucker charges by weight / space |
| Bank charges on LC / TT | Amount (invoice value) | Bank fee tracks payment value, not qty |
ERPNext v16 supports Distribute Charges Based On = Amount, Qty or Distribute Manually. For weight-based distribution you need a custom script on the LCV or a server script; talk to us if you want one. 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).
Related but separate: IGST on imports
So nobody mixes this up with the FX story: on imports of goods you pay IGST at customs (section 3(7) of the Customs Tariff Act), and you claim it as input tax on the basis of the Bill of Entry, separately from the supplier invoice. That is a compliance flow, not an FX flow, and neither the LCV nor Exchange Gain/Loss touches it. If IGST amounts are landing in an LCV correction, the wires are crossed: book them in a Purchase Invoice or Journal Entry against your import IGST account instead, and check the treatment with your CA.
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, ERPNext's inventory still shows the old per-unit valuation; that trade-off exists in every ERP.
How do gates and automation make the fix permanent?
You now know the workflow, but people forget. The answer is a check on the GRN so a stale rate can't be submitted. Pick the form that suits you: client script, server script, an app hook, or an n8n workflow. All four give the same guarantee.
Four automations to copy that prevent a stale-rate GRN from ever being submitted again: client script, server script, hooks.py, and an n8n workflow.
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 a reference rate (FBIL, or a rate API) 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 it weekly.
When is any of this material?
| Fix immediately | Fix process, backlog the history |
|---|---|
| 1% FX move on a ₹50L consignment = ₹50,000 in the wrong account | High-margin imports where FX ≪ contribution margin |
| Low-margin imports (chemicals, commodities, electronics) | PO→GRN gap < 5 days on average |
| PO→GRN gap consistently > 2 weeks | You're mid-year-end and can't repost the SLEs anyway |
| Any FSA / audit engagement in the next 6 months | The delta rounds to noise on your P&L |
Either way, the process fix costs a few seconds per GRN.
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 starting point; treat these numbers as a template for your CA or auditor to sign off, not as a rule:
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: FBIL reference rate on the GRN posting date; fall back to the company's bank TT buying rate if it is not yet published.
Three things to notice about this being written down rather than left to individual judgement:
- Auditors prefer a written policy to case-by-case calls. A written threshold with a documented rate source is far easier to defend at year-end than "we corrected it when it looked material".
- The numbers should match your company's materiality. ₹5,000 is a placeholder; a much larger company should run a much higher threshold. Anchor it to the materiality benchmark your auditor already uses, not a number from a blog post.
- A 0.5% move is a low threshold. USD/INR can move that much in a week, so expect corrections on a fair share of import GRNs at first, falling as the buying team times POs closer to shipment.
Common questions from the buying team
+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: a reference rate, the bank's TT rate, or something else?
Whatever your accounting policy defines as the spot rate on the receipt date. Many companies use the FBIL reference rate (published since 2018 in place of RBI's) for consistency; companies with an active treasury desk often use their bank's TT buying rate. The critical part is the date (receipt day, not PO day, not invoice day), more than the source. Agree the source with your auditor.
+How does the LCV FX top-up interact with a closed accounting period?
It depends on whether your closing setup (a closed Accounting Period, or the frozen-till date in Accounts Settings) blocks posting into the closed period. If it doesn't, an LCV reposts outgoing SLEs and changes COGS in the closed period; talk to your auditor before that 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, a negligible cost on any reasonable server (see the Hetzner vs AWS guide for hosting). 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 code paths cited above are from v16.28.0, but the same logic (the GRN freezes stock at conversion_rate, the PI posts the FX difference to Exchange Gain/Loss, the LCV is the supported correction) is in v14 and v15 with small differences. Test the check script on your version before you rely on it.
+What if the supplier invoices in INR and there's no FX at all?
Then none of this applies: the conversion_rate is 1 and nothing drifts. This post is for imports invoiced in a foreign currency (USD, EUR, CNY, AED and so on).
What is the bottom line?
ERPNext's landed-cost and FX handling is closer to correct than forum threads suggest. 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 usually the PO's rate carrying through to the GRN because nobody overrode it. Fix that one habit, put the check in, and most of the landed-cost trouble goes. The ERPNext platform page covers how we work with ERPNext more widely, and the distribution page shows the buying and stock flows running live.
Comments & ratings
No comments yet. Start a new discussion.
Manoj · Published on 23 July 2026
Worked with us? Leave a Google review