Wiki

Wiki

Open in ChatGPT
Ask ChatGPT about this page
Open in Claude
Ask Claude about this page

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_rate fails (network error, missing setup), the row is still created with base_rate = 0.0 rather 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_rate is 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 = 0 and amount_inr = 0 do not need a row in exchange_rates for their currency. They are never passed through get_buffered_rate. However, EUR must still exist in exchange_rates because 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 = 500
  • rate = 28
  • exchange_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.

Last updated 3 months ago
Was this helpful?
Thanks!