---
title: "Cotonou Import Duty"
space: "Wiki"
url: "https://jmtradelink.com/docs/the-costing-workflow/cotonou-import-duty"
updated: "2026-06-22"
---

# Cotonou Import Duty

**Section:** The Costing Workflow

![Cotonou Import Duty diagram](/files/cotonou-import-duty.png)


---

## Overview

![Cotonou Import Duty screenshot](/files/cotonou-import-duty448775.png)


When rice or other commodity shipments land at the Port of Cotonou (Benin), the importer pays a structured set of import duties and clearing charges before the goods can leave the port. The Export Costing module reproduces the client's physical "Estimate Cost" worksheet (reference shipment JMC-252623) inside the `compute_pod_clearing` method in `pod_clearing.py`. The result is a single field — `pod_clearing_per_bag` — that rolls into the full landed cost per bag.

This build-up is **inert by default.** `convertable_duty_rate` has no factory default and is set manually on the costing document (it is not part of the Costing Template); its absence is the inertness gate. Until a non-zero `convertable_duty_rate` is present, every guard clause inside `compute_pod_clearing` short-circuits and `reset_pod_clearing` zeroes all output fields. This means legacy costings and purely selling-side costings carry `pod_clearing_per_bag = 0` without any error or side-effect.

---

## Where the code lives

| File | Role |
|---|---|
| `export_management/doctype/export_costing/pod_clearing.py` | `PodClearingMixin` — all duty/clearing computation |
| `export_management/doctype/export_costing/export_costing.py` | `ExportCosting` document; calls `compute_pod_clearing` from inside `calculate_destination_block` during `validate` |

`ExportCosting` inherits from both `CombinationEngineMixin` and `PodClearingMixin`:

```python
class ExportCosting(CombinationEngineMixin, PodClearingMixin, Document):
```

`calculate_destination_block` is the top-level orchestrator for all destination-side economics. It calls `compute_pod_clearing` after establishing `cost_per_bag_destination` and `pod_expense_per_bag`, then folds `pod_clearing_per_bag` into the final `landed_cost_per_bag`.

---

## Lane parameters and snapshotting

Four duty-lane parameters are snapshotted from the Costing Template onto each costing document at template-population time:

| Field | What it represents |
|---|---|
| `bfu_percent` | BFU (Bureau de Fret et des Unités) levy — percent of CFA customs value |
| `transitor_per_container` | Fixed transit fee per container (CFA) |
| `tax_percent` | General tax on the convertable + BFU + transitor base |
| `commission_per_bag` | Clearing-agent commission charged per bag, added after the total-duty-per-bag division |

These four fields are listed in the constant `IMPORT_DUTY_LANE_PARAMS` at the top of `pod_clearing.py` and are snapshotted with this pattern in `populate_from_template`:

```python
for fieldname in IMPORT_DUTY_LANE_PARAMS:
    if not flt(self.get(fieldname)) and template.get(fieldname):
        self.set(fieldname, template.get(fieldname))
```

Snapshotting works identically to `cfa_rate_per_eur`: once a costing document has a value, later changes to the Costing Template do not overwrite it. This protects historical costings from drifting when a template's defaults are updated.

Two additional duty-rate fields exist only on the costing document itself and are **not** part of the Costing Template; they must be set manually:

| Field | What it represents |
|---|---|
| `convertable_duty_rate` | Rate for the "convertable" duty column (no default — its absence is the inertness gate) |
| `customs_duty_rate` | Rate for the standard customs column |

---

## Guard clause — when compute_pod_clearing is inert

```python
convertable_rate = flt(self.convertable_duty_rate)
total_bags = cint(self.total_bags)
if not (convertable_rate and total_bags and flt(self.cif_per_mt_eur)):
    self.reset_pod_clearing()
    return
```

All three conditions must be truthy:

1. `convertable_duty_rate` is non-zero (the absence-gate — has no default and must be set manually)
2. `total_bags` is non-zero (the shipment has items)
3. `cif_per_mt_eur` is non-zero (the CIF cost has been computed upstream)

A costing that has never had its duty rates entered will have `convertable_duty_rate = 0`, so it exits here immediately and every output field is zeroed via `reset_pod_clearing`.

---

## The EUR column is the golden basis

The client's "Estimate Cost" spreadsheet formats the customs-value cell as **EUR currency**, not USD. This is not cosmetic — the duty is legally assessed against the EUR-denominated CIF value because Benin's CFA franc is pegged to the euro (655.957 CFA per EUR, fixed). Using the USD-denominated CIF value instead would overstate the customs base by roughly 17% (the EUR/USD spread at typical rates).

The code therefore uses `cif_per_mt_eur` — the cost per metric tonne expressed in EUR — as the starting point for the entire duty build-up. `cif_per_mt_usd` is computed in parallel but only appears in the informational USD-path landed cost, never in the duty calculation.

---

## Step-by-step build-up

### Step 1 — Duty base (`duty_base_grand_total_eur`)

```python
self.duty_base_grand_total_eur = flt(mround(flt(self.cif_per_mt_eur), 5) * flt(self.total_gross_mt))
```

`mround` replicates Excel's `MROUND` function: it rounds the per-MT EUR figure to the **nearest 5** before multiplying by total gross MT. This is required to match the client's sheet exactly — the customs authority rounds the per-MT tariff value to the nearest 5 EUR before applying rates.

```python
def mround(value: float, multiple: float) -> float:
    if not multiple:
        return flt(value)
    return round(flt(value) / multiple) * multiple
```

**Example (demo shipment):** If `cif_per_mt_eur = 312.47` and `total_gross_mt = 240`, then `mround(312.47, 5) = 310`, and `duty_base_grand_total_eur = 310 × 240 = 74,400 EUR`.

### Step 2 — CFA duty values

Two parallel duty values are computed from the same EUR base, using two different rate columns from the Benin tariff schedule:

```python
self.cfa_customs_value = flt(self.duty_base_grand_total_eur * flt(self.customs_duty_rate))
self.cfa_convertable_value = flt(self.duty_base_grand_total_eur * convertable_rate)
```

| Field | Formula | Purpose |
|---|---|---|
| `cfa_customs_value` | duty base × `customs_duty_rate` | Standard customs column — used as the BFU base |
| `cfa_convertable_value` | duty base × `convertable_duty_rate` | Convertable column — used as the tax base |

These two values represent different columns in the Beninese customs tariff structure. They are not interchangeable.

### Step 3 — BFU amount

```python
self.bfu_amount = flt(self.cfa_customs_value * flt(self.bfu_percent))
```

BFU (Bureau de Fret et des Unités) is a levy assessed as a percentage of the **customs column value** (`cfa_customs_value`).

> **Gotcha — cross-column design:** BFU is taken on `cfa_customs_value` (the CUSTOMS column), but the tax base in the next step uses `cfa_convertable_value` (the CONVERTABLE column). This cross-column behavior exactly matches the client's "Estimate Cost" sheet. It has been flagged to the client for explicit confirmation and is intentional. **Do not "fix" this to use a single column.** The comment in the source reads: *"cross-column on purpose (matches the sheet; flagged to client for confirmation), do NOT 'fix' to a single column."*

### Step 4 — Transitor amount

```python
self.transitor_amount = flt(flt(self.transitor_per_container) * cint(self.total_containers))
```

Transitor (transit road fee) is a flat fee charged per container. It is independent of the cargo value. `total_containers` is summed from all item lines during `calculate_item_totals`.

### Step 5 — Tax amount

```python
self.tax_amount = flt(
    (self.cfa_convertable_value + self.bfu_amount + self.transitor_amount) * flt(self.tax_percent)
)
```

The tax base is the sum of three components:

| Component | Source |
|---|---|
| `cfa_convertable_value` | CONVERTABLE column duty value |
| `bfu_amount` | BFU levy (computed above from the CUSTOMS column) |
| `transitor_amount` | Per-container transit fee |

Note that even though BFU was computed from the customs column, it enters the tax base here alongside the convertable value. This is the cross-column design described in Step 3 — BFU crosses over from the customs side into the convertable/tax side.

### Step 6 — POD clearing total

```python
self.pod_clearing_in_total = flt(self.bfu_amount + self.transitor_amount + self.tax_amount)
```

The three charges (BFU, transitor, and tax) are summed into a single CFA total for the entire shipment. The customs duty itself (`cfa_customs_value`, `cfa_convertable_value`) does not appear here directly — those are intermediate bases, not charges added to the clearing total.

### Step 7 — Per-bag clearing cost

```python
self.pod_clearing_per_bag = flt(
    self.pod_clearing_in_total / total_bags + flt(self.commission_per_bag)
)
```

The total clearing cost is divided across all bags, then the clearing-agent commission per bag is added on top. `commission_per_bag` is a flat CFA amount per bag, not a percentage — it is snapshotted from the Costing Template alongside the other lane params.

---

## How pod_clearing_per_bag enters landed cost

After `compute_pod_clearing` returns, `calculate_destination_block` assembles the full landed cost:

```python
self.landed_cost_per_bag = flt(
    self.cost_per_bag_destination + self.pod_clearing_per_bag + self.pod_expense_per_bag
)
```

| Component | What it covers |
|---|---|
| `cost_per_bag_destination` | Goods CIF (with finance cost and profit markup) converted to CFA via the EUR peg |
| `pod_clearing_per_bag` | Import duty + clearing charges (this document) |
| `pod_expense_per_bag` | Destination-stage charges from the charges table (e.g. port handling, fumigation) |

The USD-path landed cost is computed in parallel by `compute_usd_landed` but uses the same `pod_clearing_per_bag` — the duty was already assessed in CFA and does not change by currency path. Only `cost_per_bag_destination_usd` differs.

---

## Complete field reference

### Inputs consumed by compute_pod_clearing

| Field | Type | Source |
|---|---|---|
| `convertable_duty_rate` | Float | Set manually on the costing (not in Costing Template) |
| `customs_duty_rate` | Float | Set manually on the costing (not in Costing Template) |
| `bfu_percent` | Float | Snapshotted from Costing Template (IMPORT_DUTY_LANE_PARAMS) |
| `transitor_per_container` | Currency | Snapshotted from Costing Template (IMPORT_DUTY_LANE_PARAMS) |
| `tax_percent` | Float | Snapshotted from Costing Template (IMPORT_DUTY_LANE_PARAMS) |
| `commission_per_bag` | Currency | Snapshotted from Costing Template (IMPORT_DUTY_LANE_PARAMS) |
| `cif_per_mt_eur` | Float | Computed by `calculate_per_mt_costs` (CIF total × finance/profit factors / gross MT / EUR rate) |
| `total_gross_mt` | Float | Summed from item lines by `calculate_item_totals` |
| `total_bags` | Int | Summed from item lines by `calculate_item_totals` |
| `total_containers` | Int | Summed from item lines by `calculate_item_totals` |

### Outputs written by compute_pod_clearing

| Field | Type | Meaning |
|---|---|---|
| `duty_base_grand_total_eur` | Float | MROUND(cif_per_mt_eur, 5) × total_gross_mt |
| `cfa_customs_value` | Currency | duty base × customs_duty_rate |
| `cfa_convertable_value` | Currency | duty base × convertable_duty_rate |
| `bfu_amount` | Currency | cfa_customs_value × bfu_percent |
| `transitor_amount` | Currency | transitor_per_container × total_containers |
| `tax_amount` | Currency | (cfa_convertable_value + bfu_amount + transitor_amount) × tax_percent |
| `pod_clearing_in_total` | Currency | bfu_amount + transitor_amount + tax_amount |
| `pod_clearing_per_bag` | Float | pod_clearing_in_total / total_bags + commission_per_bag |

---

## Sequence diagram

```
validate()
  └─ calculate_destination_block()
       ├─ [guard: total_bags and EUR rate present?]
       ├─ cost_per_bag_destination = CIF cost per bag in CFA (EUR path)
       ├─ pod_expense_per_bag = destination-stage charges per bag
       ├─ compute_pod_clearing()
       │    ├─ [guard: convertable_rate, total_bags, cif_per_mt_eur all truthy?]
       │    │    └─ [No] → reset_pod_clearing() → return
       │    ├─ duty_base_grand_total_eur = mround(cif_per_mt_eur, 5) × total_gross_mt
       │    ├─ cfa_customs_value   = duty_base × customs_duty_rate
       │    ├─ cfa_convertable_value = duty_base × convertable_duty_rate
       │    ├─ bfu_amount          = cfa_customs_value × bfu_percent        ← CUSTOMS column
       │    ├─ transitor_amount    = transitor_per_container × total_containers
       │    ├─ tax_amount          = (cfa_convertable_value + bfu + transitor) × tax_percent  ← CONVERTABLE column
       │    ├─ pod_clearing_in_total = bfu + transitor + tax
       │    └─ pod_clearing_per_bag  = (pod_clearing_in_total / total_bags) + commission_per_bag
       └─ landed_cost_per_bag = cost_per_bag_destination + pod_clearing_per_bag + pod_expense_per_bag
```

---

## Key design decisions (and why)

**EUR as the duty basis, not USD.** The Beninese customs authority assesses duty against a EUR-denominated CIF value because the CFA franc is pegged to the euro. The client's spreadsheet formats that cell in EUR. Computing from `cif_per_mt_usd` would overstate the duty base by approximately 17% at prevailing EUR/USD rates.

**MROUND to the nearest 5.** The customs tariff schedule uses a rounded per-MT value, not the precise computed one. Excel's `MROUND(value, 5)` is the exact operation the client's sheet performs. Without this rounding step, duty totals would diverge from the reference sheet even when all rates are identical.

**Cross-column BFU/tax split.** BFU is calculated on the customs-column value; the tax base uses the convertable-column value (plus BFU and transitor). This is not a bug — it mirrors the structure of the Beninese import duty schedule as represented in the client's sheet and has been explicitly flagged to the client. The source carries a prominent `# do NOT "fix"` comment for this reason.

**Snapshotting lane params.** The four `IMPORT_DUTY_LANE_PARAMS` fields (`bfu_percent`, `transitor_per_container`, `tax_percent`, `commission_per_bag`) are written to the costing document on first population and never overwritten. This is the same philosophy applied to `cfa_rate_per_eur`: a costing is a point-in-time snapshot, and later changes to a Costing Template's defaults must not silently change the economics of a costing that was already reviewed or selected. The two duty rates (`convertable_duty_rate`, `customs_duty_rate`) are not in the Costing Template at all — they are entered directly on the costing.

**Inertness by absent `convertable_duty_rate`.** Rather than a boolean "enable import duty" flag, the system uses the natural absence of the duty rate as the gate. A costing without `convertable_duty_rate` will have a zero value for this field, which is falsy, so `compute_pod_clearing` exits immediately and leaves all duty fields at zero. This makes the default safe for selling-side or origin-only costings with no additional configuration.
