Wiki

Wiki

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

Cotonou Import Duty

Cotonou Import Duty

Section: The Costing Workflow

Cotonou Import Duty diagram


Overview

Cotonou Import Duty screenshot

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:

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:

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

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)

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.

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:

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

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

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

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

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

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:

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.

Last updated 3 months ago
Was this helpful?
Thanks!