Currency & Exchange Rates
Currency & Exchange Rates
Every monetary figure in an Export Costing document passes through a single, explicit exchange-rate layer. There is no implicit ERPNext currency conversion during validation — the costing owns its own FX snapshot and controls precisely when to apply buffered versus unbuffered rates. This page explains the model, the data structure, and the reasoning behind every design choice.
The exchange_rates Child Table
Each Export Costing document carries an exchange_rates child table (ExportCostingExchangeRate). Every row represents one foreign currency and records two rates, both expressed as INR per unit of that currency.
| Field | Type | Description |
|---|---|---|
currency |
Link → Currency | The foreign currency (e.g. USD, EUR). INR rows are never added — INR is the company currency and always resolves to 1.0. |
base_rate |
Float (precision 9) | The market rate at the time the row was created, suggested by ERPNext's get_exchange_rate(currency, "INR"). |
rate_date |
Date | Snapshot date, set to today() when the row is auto-created. |
buffered_rate |
Float (precision 9, read-only) | Computed by set_exchange_rate_buffers as base_rate + fx_buffer. Never entered manually. |
The fx_buffer field lives on the parent costing document (a Float field, defaulted from Export Settings on first validate). It is the same margin added to every currency row, every time validate() runs.
How buffered_rate is populated
# export_costing.py — set_exchange_rate_buffers
def set_exchange_rate_buffers(self):
for row in self.exchange_rates:
row.buffered_rate = flt(row.base_rate) + flt(self.fx_buffer)
This runs on every validate(). If you increase fx_buffer and save, all buffered_rate values update immediately. The base_rate itself is never touched after creation — it is a historical snapshot of the market rate at costing time.
The Deliberate Two-Rate Split
The costing engine uses two different rates for two different purposes. This is intentional and mirrors the client's Excel model.
Buffered rate — for cost calculations
All charge rows in the Ex-Works → CIF cascade convert at the buffered rate via get_buffered_rate. This is the conservative, worst-case rate: it inflates foreign-currency costs before they enter the INR cascade, protecting the margin in case the rate moves against you between costing and payment.
# export_costing.py — calculate_charges (docstring excerpt)
"""Foreign-currency costs convert at the BUFFERED rate (conservative, as the
client's Excel does)."""
row.qty = self.resolve_charge_qty(row)
row.exchange_rate_used = self.get_buffered_rate(row.currency)
row.amount_inr = flt(row.qty) * flt(row.rate) * row.exchange_rate_used
Note: rows with qty_basis == "Percent of Subtotal" bypass FX conversion entirely — they are computed as a percentage of the stage subtotal with exchange_rate_used = 1 and no currency lookup.
get_buffered_rate will throw if no rate row exists for the requested currency:
def get_buffered_rate(self, currency: str) -> float:
if not currency or currency == COMPANY_CURRENCY:
return 1.0
for row in self.exchange_rates:
if row.currency == currency:
return flt(row.buffered_rate)
frappe.throw(
_("Add an exchange rate row for {0} — it is used in the charges table.").format(currency)
)
This fail-loud behaviour is intentional: a silently missing rate would produce a zero-valued charge with no warning, which would corrupt the costing silently.
Base rate — for per-MT quote views
The per-MT figures displayed to buyers (FOB per MT, CIF per MT in USD and EUR, and the quoted price per MT) convert at the base rate via get_base_rate. The base rate is the unpadded market rate — it gives the buyer a cleaner, more competitive number while the buffer remains embedded in the underlying INR cost total.
# export_costing.py — calculate_per_mt_costs (docstring)
"""Per-MT views convert at the BASE rate (quote side of the buffer)."""
def calculate_per_mt_costs(self):
cost_per_mt_inr = self.get_cost_per_mt_inr()
usd_rate = self.get_base_rate("USD")
eur_rate = self.get_base_rate("EUR")
fob_per_mt_inr = flt(self.fob_total) / flt(self.total_gross_mt) if flt(self.total_gross_mt) else 0.0
self.fob_per_mt_usd = fob_per_mt_inr / usd_rate if usd_rate else 0.0
self.cif_per_mt_usd = cost_per_mt_inr / usd_rate if usd_rate else 0.0
self.cif_per_mt_eur = cost_per_mt_inr / eur_rate if eur_rate else 0.0
get_base_rate returns 0.0 (not a throw) when no rate row exists. A zero rate produces a zero per-MT figure, which is visible on the form and signals the missing row without blocking the save.
Summary table
| Use case | Method | Rate used | Behaviour when row is missing |
|---|---|---|---|
Charge amount_inr in cascade (Ex-Works → CIF) |
get_buffered_rate |
base_rate + fx_buffer |
frappe.throw |
| FOB / CIF per MT in USD or EUR | get_base_rate |
base_rate |
Returns 0.0 |
| Quoted price per MT in quote currency | get_base_rate + explicit throw in set_quoted_price |
base_rate |
frappe.throw if quote currency has no row |
| Destination-to-INR bridge (CFA block) | get_base_rate("EUR") divided by cfa_rate_per_eur |
base_rate of EUR |
Returns 0.0 |
How FX Rows Are Created
FX rows are never created by hand during normal flow. Two methods handle automatic creation.
ensure_exchange_rate_rows
Called by populate_from_template. It scans the charges table for every foreign currency used in non-Destination rows, then adds the always-required EUR row regardless:
def ensure_exchange_rate_rows(self):
needed = {
row.currency
for row in self.charges
if row.currency and row.currency != COMPANY_CURRENCY and row.stage != DESTINATION_STAGE
}
# EUR drives the CFA destination block even when no charge is in EUR
needed.add("EUR")
self.ensure_exchange_rate_rows_for_currencies(needed)
EUR is hardcoded into the needed set because the CFA destination block (get_destination_to_inr_rate) divides EUR/INR by the CFA-per-EUR conversion factor to produce an INR-per-CFA rate. This calculation fires for every Cotonou-destined costing, even if every charge on that costing is priced in USD. Without a proactive EUR row, the destination block would silently produce zero.
ensure_exchange_rate_rows_for_currencies
The lower-level implementation, also called directly by the combination engine. It fetches today's live rate from ERPNext for each missing currency and appends a new row:
def ensure_exchange_rate_rows_for_currencies(self, currencies: set[str]):
from erpnext.setup.utils import get_exchange_rate
existing = {row.currency for row in self.exchange_rates}
needed = {currency for currency in currencies if currency and currency != COMPANY_CURRENCY}
for currency in sorted(needed - existing):
try:
base_rate = flt(get_exchange_rate(currency, COMPANY_CURRENCY))
except Exception:
base_rate = 0.0
self.append(
"exchange_rates",
{"currency": currency, "base_rate": base_rate, "rate_date": today()},
)
Key design decisions here:
- The method is additive only — it never touches existing rows. Rates the user has already adjusted are preserved.
- If
get_exchange_ratefails (network error, missing setup), the row is still created withbase_rate = 0.0rather than raising. The user will see the zero and can enter the correct rate manually. - Currencies are iterated in
sorted()order for deterministic row ordering in the child table.
Gotcha:
get_exchange_rateis a network call that hits ERPNext's currency exchange provider. It must never be called inside a loop over combinations — that would make generation latency proportional to the number of unique currencies × the number of combinations, potentially hundreds of HTTP calls.
The Combination Engine and FX Pre-creation
The combination engine (generate_combinations) evaluates every valid vendor-rate permutation by running the full calculation pipeline on transient in-memory clones of the costing. Each clone calls set_exchange_rate_buffers, calculate_charges, calculate_per_mt_costs, set_quoted_price, and calculate_destination_block — all of which call get_buffered_rate or get_base_rate.
To avoid calling get_exchange_rate once per combination, the engine pre-creates all needed FX rows on the parent document in one pass, then persists them with self.save(), before the generation loop begins:
# combination_engine.py — generate_combinations
self.ensure_exchange_rate_rows_for_currencies({row.currency for row in self.raw_rates})
self.save()
# ... then build and evaluate combinations
Each transient clone is created with frappe.copy_doc(self), which carries the already-populated exchange_rates table. The clone's get_buffered_rate / get_base_rate reads from this inherited table — no further database or network calls happen per combination.
When the user eventually selects a combination and that combination's rates are applied back to the parent, set_exchange_rate_buffers runs again (as part of the next validate()) to recompute buffered rates in case fx_buffer has changed in the meantime.
Destination-Stage Charges Are Excluded From FX Conversion
Charges in the Destination stage (e.g. port-of-discharge customs, clearing fees in CFA francs for Cotonou shipments) are deliberately excluded from the INR cascade. In calculate_charges, after the Ex-Works → CIF cascade completes, Destination rows are zeroed out:
for row in self.charges:
if row.stage == DESTINATION_STAGE:
row.qty = self.resolve_charge_qty(row)
row.exchange_rate_used = 0
row.amount_inr = 0
These charges stay in destination_currency and feed the per-bag destination cost block (calculate_destination_block) through a separate conversion path (get_destination_to_inr_rate). The INR amount is derived only for the final landed cost per bag display, not for the CIF total or any upstream stage subtotal.
Gotcha: Destination charges that use
exchange_rate_used = 0andamount_inr = 0do not need a row inexchange_ratesfor their currency. They are never passed throughget_buffered_rate. However, EUR must still exist inexchange_ratesbecause the destination block itself uses the EUR base rate to bridge destination currency → EUR → INR.
validate_destination_charges enforces that Destination rows can only use Per Bag or Lumpsum as their quantity basis, and that all Destination rows must be in the parent document's destination_currency. This ensures the destination block receives consistent inputs.
Example: USD Freight at a ₹0.50 Buffer
Suppose the Export Settings carry fx_buffer = 0.50. The USD row after ensure_exchange_rate_rows_for_currencies might look like:
| currency | base_rate | buffered_rate | rate_date |
|---|---|---|---|
| USD | 83.42 | 83.92 | 2026-06-22 |
| EUR | 91.10 | 91.60 | 2026-06-22 |
A freight charge of USD 28/MT on 500 MT of gross weight calculates:
qty = 500rate = 28exchange_rate_used = 83.92(buffered)amount_inr = 500 × 28 × 83.92 = ₹11,74,880
The per-MT CIF figure displayed to the buyer then divides the total INR cost by 83.42 (base rate), producing a slightly lower per-MT USD figure — the buffer margin is already baked into the INR cost pool and does not compound into the quoted price.