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
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
);
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:
- Duplicate duty postings if the carrier webhook retries
- 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")
);
}
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)