---
title: "Currency & Exchange Rates"
space: "Wiki"
url: "https://jmtradelink.com/docs/setup/currency-exchange-rates"
updated: "2026-06-22"
---

# 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

```python
# 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.

```python
# 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:

```python
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.

```python
# 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:

```python
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:

```python
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:

```python
# 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:

```python
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.
