---
title: "Cost Build-up"
space: "Wiki"
url: "https://jmtradelink.com/docs/the-costing-workflow/cost-build-up"
updated: "2026-06-22"
---

# Cost Build-up

**Section: The Costing Workflow**

![Cost Build-up diagram](/files/cost-build-up.png)


---

## Overview

The `ExportCosting` document computes a single INR cost stack that runs from the farm gate (Ex-Works) all the way to the vessel (CIF). Every charge row in the `charges` child table belongs to one of five stages: `Ex-Works`, `FOB`, `CFR`, `CIF`, or `Destination`. The first four form a cumulative cascade; the fifth is a separate destination-side block that never touches the INR total.

The cascade is defined in `constants.py`:

```python
CASCADE_STAGES = ["Ex-Works", "FOB", "CFR", "CIF"]
DESTINATION_STAGE = "Destination"
```

The company's books currency is INR (`COMPANY_CURRENCY = "INR"`). Every foreign-currency charge is converted to INR before it enters the running total.

---

## The Cascade: `calculate_charges`

Called once per `validate()` pass, `calculate_charges` iterates over `CASCADE_STAGES` in order. For each stage it scans the full `charges` table and processes only the rows whose `stage` matches the current stage. A `running_total` accumulates across all four stages, so each stage total already contains every rupee from every earlier stage.

```python
running_total = 0.0
stage_totals = {}

for stage in CASCADE_STAGES:          # Ex-Works → FOB → CFR → CIF
    stage_start = running_total       # subtotal at the START of this stage
    for row in self.charges:
        if row.stage != stage:
            continue
        if row.qty_basis == "Percent of Subtotal":
            row.qty = 1
            row.exchange_rate_used = 1
            row.amount_inr = flt(row.rate) / 100 * stage_start
        else:
            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
        running_total += flt(row.amount_inr)
    stage_totals[stage] = running_total

self.ex_works_total = stage_totals["Ex-Works"]
self.fob_total      = stage_totals["FOB"]
self.cfr_total      = stage_totals["CFR"]
self.cif_total      = stage_totals["CIF"]
```

### Why the running total carries forward

Each stage total is a snapshot of `running_total` **after** all rows in that stage have been added. Because the variable never resets between stages, `fob_total` already contains `ex_works_total`; `cfr_total` already contains `fob_total`; and so on. This mirrors the client's Excel model where each Incoterms column is a cumulative cost, not a slice.

| Field stored | What it represents |
|---|---|
| `ex_works_total` | Sum of all Ex-Works charges |
| `fob_total` | Ex-Works + all FOB charges |
| `cfr_total` | FOB + all CFR charges (freight) |
| `cif_total` | CFR + all CIF charges (insurance) |

### Percent of Subtotal rows

When `qty_basis == "Percent of Subtotal"` the row's `rate` is treated as a percentage applied to `stage_start` — the running total **at the moment that stage began**, before any row in the current stage has been added.

This is intentional: a commission expressed as "2% of the Ex-Works sub-total" should reference what was accumulated before the current stage, not a mid-stage partial sum. The fields `qty` and `exchange_rate_used` are set to `1` so the stored values are meaningful for audit purposes (the formula resolves to `rate% × stage_start`).

> **Gotcha:** Percent of Subtotal is evaluated against the stage's *opening* subtotal, not the total after the current stage's other rows. Row order within a stage does not affect it.

---

## Quantity Resolution: `resolve_charge_qty`

Before any INR amount can be computed, the engine must know **how many units** a charge applies to. `resolve_charge_qty(row)` maps the charge row's `qty_basis` field to a concrete number.

### Scope: item vs. shipment

The first decision the function makes is which set of weights to use:

- If `row.item_code` is set, the function finds the matching `ExportCostingItem` row and reads its own `net_weight_mt`, `gross_weight_mt`, `total_bags`, and `no_of_containers`.
- If `row.item_code` is blank, it reads the shipment-level totals: `self.total_net_mt`, `self.total_gross_mt`, `self.total_bags`, `self.total_containers`.

This is why a goods-procurement charge (e.g. rice purchase cost) carries an `item_code` — a multi-item shipment needs separate purchase rates for each commodity, and the weight basis must stay scoped to that item's containers.

If `item_code` is set but the item is not found in `self.items`, the function returns `0.0` rather than throwing, so a stale row does not crash validation.

### Basis-to-quantity mapping

| `qty_basis` | Resolves to |
|---|---|
| `Per Net MT` | `net_weight_mt` (item or shipment) |
| `Per Gross MT` | `gross_weight_mt` (item or shipment) |
| `Per Kg` | `net_weight_mt × 1000` |
| `Per Bag` | `total_bags` (item or shipment) |
| `Per Container` | `no_of_containers` (item or shipment) |
| `Per BL` | `self.no_of_bl` (always shipment-level — a BL covers the whole consignment) |
| `Lumpsum` | `1` |
| `Manual` | `row.qty` (the user-entered value; not recomputed) |

> **Why Per Kg = net MT × 1000:** Rice and similar commodities are commercially priced on net weight. Warehouse handling fees quoted per kg from a vendor come back as a flat rate; the engine expresses the shipment in kg by scaling the net MT figure rather than storing a separate kg field.

> **Per BL is always shipment-scope.** A Bill of Lading covers the full shipment regardless of how many items are on it, so `no_of_bl` is never an item attribute.

### Manual basis

When `qty_basis == "Manual"` the function returns whatever is already in `row.qty` without touching it. This is the escape hatch for one-off lump charges that don't fit any formula — the user types the quantity directly.

---

## INR Conversion: the buffered exchange rate

For every non-Percent row, `amount_inr` is:

```
amount_inr = qty × rate × buffered_exchange_rate
```

`get_buffered_rate(currency)` reads the `exchange_rates` child table and returns `base_rate + fx_buffer`. The buffer is a conservative safety margin (set once from Export Settings, stored on the costing so it never shifts retroactively).

If INR is the charge currency, `get_buffered_rate` returns `1.0` so no conversion happens.

If no exchange rate row exists for the charge's currency, `get_buffered_rate` throws immediately with a clear message rather than silently returning zero. This is by design — a missing rate would silently understate cost.

> **Per-MT display fields use the BASE rate, not the buffered rate.** `calculate_per_mt_costs` converts `fob_total`, `cif_total` (finance- and profit-loaded) to USD/EUR using `get_base_rate` (no buffer added), because those are quote-side numbers. The buffer is cost-side conservatism; the quote is computed separately from `get_cost_per_mt_inr`.

---

## Destination-Stage Rows: excluded from the INR cascade

After the four-stage cascade loop completes, `calculate_charges` runs a second pass over only the `Destination` rows:

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

Both `exchange_rate_used` and `amount_inr` are zeroed. `qty` is still resolved (so the user can see the computed unit count in the table) but the row contributes nothing to `cif_total` or any stage total.

### Why destination rows are excluded

The destination block represents costs incurred by the buyer's clearing agent in Cotonou (or another port of discharge) — customs duty, transit fees, local transport. These are not the exporter's cost; they are quoted to the buyer as a separate landed-cost figure. Including them in the INR cascade would overstate the exporter's selling cost and corrupt the margin calculation.

Instead, these rows feed `calculate_destination_block`, which produces `cost_per_bag_destination` and related fields in the buyer's destination currency.

### Validation rules for destination rows

`validate_destination_charges` enforces two invariants before the main loop runs:

1. `qty_basis` must be either `Per Bag` or `Lumpsum` (the only two bases defined in `DESTINATION_ALLOWED_BASES`).
2. `currency` must match `self.destination_currency` (e.g. XOF/CFA).

> **Gotcha:** If you add a `Per Net MT` destination charge, validation throws before `calculate_charges` even runs. Only `Per Bag` and `Lumpsum` are allowed.

---

## Layering Finance Cost and Profit: `get_cost_per_mt_inr`

After `calculate_charges` produces `cif_total`, the costing layers two multipliers on top before expressing the result per gross MT:

```python
def get_cost_per_mt_inr(self) -> float:
    if not flt(self.total_gross_mt):
        return 0.0
    cost_total = (
        flt(self.cif_total)
        * (1 + flt(self.finance_cost_percent) / 100)
        * (1 + flt(self.profit_percent) / 100)
    )
    return cost_total / flt(self.total_gross_mt)
```

| Field | Role |
|---|---|
| `finance_cost_percent` | Capital cost of financing the shipment (bank charges, LC cost). Applied as a percentage of `cif_total`. |
| `profit_percent` | Desired margin. Applied on top of the finance-loaded cost. |
| `total_gross_mt` | Gross MT divides the INR total into a per-MT figure. |

The multipliers are chained, not added. A `finance_cost_percent` of 1.5 and a `profit_percent` of 3.0 yields a factor of `1.015 × 1.030 = 1.04545`, not `1.045`. This matches the client's sheet where margin is calculated on the fully-loaded (post-finance) cost.

### Why gross MT, not net MT

The quoted price per MT is in **gross** MT terms because the commodity is sold by the bag/container load, not stripped of packaging weight. Gross MT is also what ocean freight is billed on, so using it as the denominator keeps the per-MT cost consistent with the freight line.

### Conversion to per-MT in quote currency

`calculate_per_mt_costs` calls `get_cost_per_mt_inr` and then divides by the **base** (unbuffered) exchange rate. It also computes a FOB view:

```python
cost_per_mt_inr = self.get_cost_per_mt_inr()
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
```

The buffer is not applied here because the output is a quoted price going to the buyer, not a cost estimate with a conservative safety margin. Note that `cif_per_mt_usd` and `cif_per_mt_eur` reflect the finance- and profit-loaded cost (via `get_cost_per_mt_inr`), while `fob_per_mt_usd` is the raw FOB subtotal divided by gross MT.

---

## Concrete Example (demo shipment)

A typical JM Tradelink costing might look like:

- 1 × 20' FCL, 960 bags of parboiled rice
- Net weight: 24.00 MT, Gross weight: 26.40 MT
- Exchange rates: USD base 83.50, buffer 0.50 → buffered 84.00; EUR base 91.20, buffer 0.50 → buffered 91.70
- `fx_buffer`: 0.50

| Stage | Representative charge | Basis | Rate | Buffered FX | `amount_inr` | Running total |
|---|---|---|---|---|---|---|
| Ex-Works | Rice (procurement) | Per Net MT | USD 315 | 84.00 | 24 × 315 × 84 = 6,35,040 | 6,35,040 |
| Ex-Works | Packaging material | Per Bag | INR 18 | 1.00 | 960 × 18 × 1 = 17,280 | 6,52,320 |
| FOB | Loading & stuffing | Per Container | INR 12,000 | 1.00 | 1 × 12,000 = 12,000 | 6,64,320 |
| FOB | Freight forwarder | Lumpsum | INR 8,500 | 1.00 | 1 × 8,500 = 8,500 | 6,72,820 |
| CFR | Ocean freight | Per Container | USD 950 | 84.00 | 1 × 950 × 84 = 79,800 | 7,52,620 |
| CIF | Insurance | Percent of Subtotal | 0.2% | — | 0.002 × 7,52,620 = 1,505 | 7,54,125 |

`cif_total` = **₹7,54,125**

With `finance_cost_percent = 1.5` and `profit_percent = 3.0`:

```
cost_total = 7,54,125 × 1.015 × 1.030 = 7,89,005 (approx)
cost_per_mt_inr = 7,89,005 / 26.40 = ₹29,886 / gross MT
cif_per_mt_usd  = 29,886 / 83.50 = ~$358 / gross MT
```

---

## Validation & `validate()` Call Order

`calculate_charges` is not called in isolation. The full `validate()` sequence ensures the inputs are ready:

```
set_defaults_from_settings    → snapshots fx_buffer, CFA rates once
set_packaging_defaults        → fills unit_weight_kg, empty_unit_weight_kg, units_per_container from Item Packaging
calculate_item_totals         → computes per-item and shipment gross/net MT, total_bags
sync_item_procurement_rows    → ensures every item has a procurement charge row
set_exchange_rate_buffers     → buffered_rate = base_rate + fx_buffer
validate_destination_charges  → rejects invalid destination rows BEFORE the loop
calculate_charges             ← the cascade runs here
calculate_per_mt_costs        → fob_per_mt_usd and cif_per_mt_usd/eur from base (not buffered) rate
set_quoted_price              → quoted_price_per_mt auto-set if blank or currency changed
calculate_destination_block   → per-bag destination cost in CFA/USD
```

If `validate_destination_charges` throws, `calculate_charges` never runs and the document is not saved. This order prevents a partially-computed costing from being persisted.
