DEV Community

Badreddine Oussaih
Badreddine Oussaih

Posted on

Booking Variable Import Duty as a First-Class Ledger Line (Not a COGS Fudge Factor)

As of July 1, 2026, the EU removed its €150 duty-free threshold for low-value
parcel imports. It's now replaced with a flat €3 customs duty per HS code
(tariff category) present in a shipment — a provisional rule running until
the EU's full customs reform lands in mid-2028.

That "per HS code, not per parcel" detail is the part that breaks naive
ledger models. A single order with a T-shirt (HS 6109) and a leather wallet
(HS 4202) now owes €6 in duty, not €3 — and that number isn't known until
you've resolved every line item's tariff classification.

I ran into this building the journaling engine for Comptyx, a cross-border
accounting tool, and it's a decent case study in why "duty" can't be a flat
percentage bolted onto COGS.

Why a flat rate breaks

Most simple ledger implementations model import cost as:

landed_cost = sale_price * duty_rate_estimate
Enter fullscreen mode Exit fullscreen mode

That's fine when duty is genuinely proportional to value. It's wrong the
moment duty is:

  • per tariff category, not per order value
  • dependent on how line items are split across HS codes
  • subject to change mid-cycle (the EU is separately floating a possible €2/HS-code handling fee later this year — unconfirmed, but the schema needs to tolerate a new fee type without a migration)

So the model needs a duty_line entity distinct from order_line, keyed
on HS code, not on order.

Schema sketch

CREATE TABLE duty_line (
    id              UUID PRIMARY KEY,
    order_id        UUID NOT NULL REFERENCES sales_order(id),
    hs_code         VARCHAR(10) NOT NULL,
    duty_amount     NUMERIC(12,2) NOT NULL,
    duty_currency   CHAR(3) NOT NULL,
    fee_type        VARCHAR(20) NOT NULL, -- 'CUSTOMS_DUTY', 'HANDLING_FEE'
    effective_date  DATE NOT NULL,
    idempotency_key VARCHAR(64) UNIQUE NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

fee_type is deliberately open-ended — when/if the handling fee gets
confirmed, it's a new row type, not a schema change.

Idempotent writes matter here more than usual

Duty gets calculated by a downstream customs/carrier system and often
arrives asynchronously, sometimes revised (carriers occasionally recompute
after physical inspection). Two failure modes to guard against:

  1. Duplicate duty postings if the carrier webhook retries
  2. Silent overwrites if a revision arrives and you don't reconcile against the original

The idempotency_key (built from order_id + hs_code + carrier_reference)
handles (1). For (2), we don't overwrite — we post a reversing entry and a
new line, so the ledger keeps a full audit trail of the correction:

@Transactional
public void postDuty(DutyLineRequest req) {
if (duttyLineRepo.existsByIdempotencyKey(req.idempotencyKey())) {
return; // already posted, no-op
}
if (req.isRevision()) {
ledgerService.reverse(req.originalLineId());
}
duttyLineRepo.save(DutyLine.from(req));
ledgerService.postJournalEntry(
JournalEntry.debit(req.dutyAmount(), req.dutyCurrency(), "import_duty_expense")
.credit(req.dutyAmount(), req.dutyCurrency(), "customs_payable")
);
}
Enter fullscreen mode Exit fullscreen mode




Multi-currency rounding, briefly

Duty is typically assessed in EUR regardless of the sale currency. If your
base ledger currency isn't EUR, that's a second conversion on top of the
sale conversion — and now you have two independently rounded amounts that
need to reconcile to the transaction total. We store both the original
duty currency amount and the converted ledger-currency amount on the same
row, rather than converting once and discarding the source value — makes
audits and rate-dispute resolution much less painful later.

Takeaway

Regulatory changes like this are a good stress test for whether your
ledger models cost as a static rate or as a first-class, independently
sourced line item. The latter is more schema work up front, but it's the
only version that survives the next rule change without a rewrite.

Built this into Comptyx (https://www.comptyx.com/) if you want to see the
approach in a live cross-border accounting product — but the pattern above
applies regardless of what you're building it in.

Top comments (0)