logistics.duty-estimate Unreviewed
Estimate import duty and import VAT on goods: customs value on a CIF basis, the caller's duty rate, VAT on top.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 17 tests, run in TypeScript, Python and Rust.
Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified customs specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
Not professional advice. This capability calculates customs duty figures from published rules. It is a software component for developers, not tax advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a customs specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
An estimate of what an import costs in tax: customs duty on the customs value, then import VAT on the customs value plus the duty. **Review:** this is money-critical and the vectors want sign-off from a customs broker before the version is relied on.
customsValue = goods + freight + insurance
duty = customsValue x dutyBasisPoints / 10000
vatValue = customsValue + duty + vatAdditions
importVat = vatValue x the VAT rate in force on importDate
totalPayable = duty + importVat
For example
duty_estimate(£1,000.00, £150.00, £15.00, 12%, £0.00, GB, STANDARD, 2026-05-01, half-up)→ customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,304.80, vat basis points 20%, import vat £260.96, total payable £400.76 £1,000 of goods, £150 freight, £15 insurance at 12% duty and 20% VATduty_estimate(£1,000.00, £150.00, £15.00, 12%, £50.00, GB, STANDARD, 2026-05-01, half-up)→ customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,354.80, vat basis points 20%, import vat £270.96, total payable £410.76 delivery inside the UK attracts VAT but not dutyduty_estimate(£1,000.00, £150.00, £15.00, 0%, £0.00, GB, STANDARD, 2026-05-01, half-up)→ customs value £1,165.00, duty basis points 0%, duty £0.00, vat value £1,165.00, vat basis points 20%, import vat £233.00, total payable £233.00 a zero duty rate still pays VAT on the freight and insurance
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def duty_estimate(goods: Money, freight: Money, insurance: Money, duty_basis_points: int, vat_additions: Money, jurisdiction: str, vat_category: str, import_date: str, rounding: RoundingMode) -> DutyEstimate
| goods | Money | the price paid or payable for the goods (transaction value) |
| freight | Money | transport, loading and handling to the border of import; zero if goods already includes it |
| insurance | Money | insurance to the border of import; zero if goods already includes it |
| duty_basis_points | int | the ad valorem duty rate from the tariff for the commodity code and origin: 1200 = 12% |
| vat_additions | Money | costs added for VAT but not duty: excise, and transport, handling and clearance to the first destination after the border |
| jurisdiction | string | where the goods are imported, as finance.tax.vat-rate knows it: GB or IE |
| vat_category | string | STANDARD, REDUCED, ZERO or EXEMPT |
| import_date | date | the date of importation, which decides the VAT rate |
| rounding | RoundingMode | how each tax is rounded to the minor unit; confirm with your customs broker |
| returns | DutyEstimate |
The type it declares, generated into your project
@dataclass(frozen=True)
class DutyEstimate:
"""Customs value, duty, the value for VAT, import VAT and the total to pay."""
#: goods + freight + insurance
customs_value: Money
duty_basis_points: int
duty: Money
#: customsValue + duty + vatAdditions
vat_value: Money
vat_basis_points: int
import_vat: Money
#: duty + importVat
total_payable: Money
Your code names it in one line, in the file that uses it
from fune.logistics.duty_estimate import duty_estimate # logistics.duty-estimate@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .finance_tax_vat_rate import vat_rate ← from finance.tax.vat-rate ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import add_money ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money ← from money.amount ^1.0.0 · built alongside by fune
from .money_apply_rate import apply_rate ← from money.apply-rate ^1.0.0 · built alongside by fune
from .logistics_duty_estimate_types import DutyEstimate
def _non_negative(name: str, amount: Money) -> None:
if amount.minor < 0:
raise ValueError("%s must not be negative, received %d" % (name, amount.minor))
def duty_estimate(
goods: Money,
freight: Money,
insurance: Money,
duty_basis_points: int,
vat_additions: Money,
jurisdiction: str,
vat_category: str,
import_date: str,
rounding: RoundingMode,
) -> DutyEstimate:
"""Import duty on the CIF customs value, then import VAT on the customs
value plus duty plus the VAT-only additions, at the rate in force on
import_date."""
_non_negative("goods", goods)
_non_negative("freight", freight)
_non_negative("insurance", insurance)
_non_negative("vatAdditions", vat_additions)
if isinstance(duty_basis_points, bool) or not isinstance(duty_basis_points, int):
raise TypeError("dutyBasisPoints must be an integer, received %r" % (duty_basis_points,))
if duty_basis_points < 0:
raise ValueError("dutyBasisPoints must not be negative, received %d" % duty_basis_points)
# CIF: duty is charged on what it cost to get the goods to the border.
customs_value = add_money(add_money(goods, freight), insurance)
duty = apply_rate(customs_value, duty_basis_points, rounding)
# VAT is charged on the duty too, and on costs after the border.
vat_value = add_money(add_money(customs_value, duty), vat_additions)
rate = vat_rate(jurisdiction, vat_category, import_date)
import_vat = apply_rate(vat_value, rate.basis_points, rounding)
return DutyEstimate(
customs_value=customs_value,
duty_basis_points=duty_basis_points,
duty=duty,
vat_value=vat_value,
vat_basis_points=rate.basis_points,
import_vat=import_vat,
total_payable=add_money(duty, import_vat),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 5 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add logistics.duty-estimate
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./logistics.duty-estimate-1.0.1-python.fune, or fetch it from a terminal with fune pull logistics.duty-estimate@1.0.1:python.
The whole function, every language, is one file too: logistics.duty-estimate-1.0.1.fune, 24,224 bytes, sha256 1c8030daab97024fc1ebae2e82887697a464ddf6ba1e738fd84f1def3a9d78bc. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before logistics.duty-estimate
after — your function gets the result and the arguments, and returns the final result.
# fune: after logistics.duty-estimate
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
# fune: replace finance.tax.vat-rate in logistics.duty-estimate
# fune: replace math.round-div in logistics.duty-estimate
# fune: replace money.add in logistics.duty-estimate
# fune: replace money.amount in logistics.duty-estimate
# fune: replace money.apply-rate in logistics.duty-estimate
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show logistics.duty-estimate --steps.
# fune: step logistics.duty-estimate after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| £1,000 of goods, £150 freight, £15 insurance at 12% duty and 20% VAT | £1,000.00, £150.00, £15.00, 12%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,304.80, vat basis points 20%, import vat £260.96, total payable £400.76 |
| delivery inside the UK attracts VAT but not duty | £1,000.00, £150.00, £15.00, 12%, £50.00, GB, STANDARD, 2026-05-01, half-up | → | customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,354.80, vat basis points 20%, import vat £270.96, total payable £410.76 |
| a zero duty rate still pays VAT on the freight and insurance | £1,000.00, £150.00, £15.00, 0%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | customs value £1,165.00, duty basis points 0%, duty £0.00, vat value £1,165.00, vat basis points 20%, import vat £233.00, total payable £233.00 |
| duty of 30.85 pence rounds half up to 31 | £12.34, £0.00, £0.00, 2.5%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | customs value £12.34, duty basis points 2.5%, duty £0.31, vat value £12.65, vat basis points 20%, import vat £2.53, total payable £2.84 |
| rounded down, duty is 30 and VAT 252 (from 252.8) | £12.34, £0.00, £0.00, 2.5%, £0.00, GB, STANDARD, 2026-05-01, down | → | customs value £12.34, duty basis points 2.5%, duty £0.30, vat value £12.64, vat basis points 20%, import vat £2.52, total payable £2.82 |
| an import in 2010 uses the 17.5% VAT rate then in force | £1,000.00, £150.00, £15.00, 12%, £0.00, GB, STANDARD, 2010-06-01, half-up | → | customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,304.80, vat basis points 17.5%, import vat £228.34, total payable £368.14 |
| reduced-rate goods | £1,000.00, £150.00, £15.00, 12%, £0.00, GB, REDUCED, 2026-05-01, half-up | → | customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,304.80, vat basis points 5%, import vat £65.24, total payable £205.04 |
| zero-rated goods pay duty but no VAT | £1,000.00, £150.00, £15.00, 12%, £0.00, GB, ZERO, 2026-05-01, half-up | → | customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,304.80, vat basis points 0%, import vat £0.00, total payable £139.80 |
| Ireland in euro at 23% | €500.00, €50.00, €0.00, 4.2%, €0.00, IE, STANDARD, 2026-05-01, half-up | → | customs value €550.00, duty basis points 4.2%, duty €23.10, vat value €573.10, vat basis points 23%, import vat €131.81, total payable €154.91 |
| a CIF invoice: freight and insurance already in the price | £1,165.00, £0.00, £0.00, 12%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | customs value £1,165.00, duty basis points 12%, duty £139.80, vat value £1,304.80, vat basis points 20%, import vat £260.96, total payable £400.76 |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| nothing to declare | £0.00, £0.00, £0.00, 12%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | customs value £0.00, duty basis points 12%, duty £0.00, vat value £0.00, vat basis points 20%, import vat £0.00, total payable £0.00 |
| freight in another currency | £1,000.00, $150.00, £15.00, 12%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | error: currency mismatch |
| a negative duty rate | £1,000.00, £150.00, £15.00, -12%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | error: dutyBasisPoints must not be negative |
| negative goods value | -£1,000.00, £150.00, £15.00, 12%, £0.00, GB, STANDARD, 2026-05-01, half-up | → | error: goods must not be negative |
| negative VAT additions | £1,000.00, £150.00, £15.00, 12%, -£0.01, GB, STANDARD, 2026-05-01, half-up | → | error: vatAdditions must not be negative |
| a jurisdiction with no VAT data | £1,000.00, £150.00, £15.00, 12%, £0.00, FR, STANDARD, 2026-05-01, half-up | → | error: no VAT rule for FR/STANDARD on 2026-05-01 |
| an unknown rounding mode | £1,000.00, £150.00, £15.00, 12%, £0.00, GB, STANDARD, 2026-05-01, nearest | → | error: unknown rounding mode "nearest" |
More from the author
## Why it is shaped this way
- **CIF basis.** The UK and the EU value imports for duty on what it cost to get them to the border: the price of the goods plus transport, insurance, loading and handling up to the place the goods enter (HMRC, "Delivery costs to include in the customs value"; EU Union Customs Code, Article 71). Charging duty on the invoice price alone is the common mistake. For a country that values on an FOB basis (the United States, for one), pass freight and insurance as zero. - **VAT goes on top of duty.** The value for import VAT is the customs value plus the duty plus incidental costs to the first destination in the country (HMRC IMPS04150; VAT Directive 2006/112/EC, Article 86). Those later costs, and any excise duty, are `vatAdditions`: VAT is charged on them, duty is not. - **The duty rate is an argument.** It depends on the commodity code, the country of origin, preferences under a trade agreement, quotas and suspensions; that lookup belongs to a tariff service, not a table here. Only ad valorem duty is modelled: specific duties (per kilogram, per litre), anti-dumping duty and excise must be worked out separately (excise then goes into `vatAdditions`). - **The VAT rate is data.** It comes from `finance.tax.vat-rate` for the jurisdiction, category and import date, so historic imports use the rate then in force. - **Rounding is the caller's choice**, applied to duty and to VAT separately, because the published guidance does not settle it and brokers' systems differ. `down` (truncate to the penny) and `half-up` are the usual choices.
## Not modelled
Low-value consignment rules (in Great Britain, VAT on goods of £135 or less is charged at the point of sale, not at import), customs duty reliefs, postponed VAT accounting (which changes when the VAT is paid, not how much), and currency conversion: every amount must be in the currency of the import declaration, and a mix is an error.
## Sources
- HMRC, "Delivery costs to include in the customs value", https://www.gov.uk/guidance/delivery-costs-to-include-in-the-customs-value - HMRC internal manual IMPS04150, "Value for import VAT: normal rules: incidental expenses", https://www.gov.uk/hmrc-internal-manuals/imports/imps04150 - Regulation (EU) No 952/2013 (Union Customs Code), Article 71; Council Directive 2006/112/EC, Article 86.
## Before you rely on this
**Not professional advice.** This capability calculates customs duty figures from published rules. It is a software component for developers, not tax advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a customs specialist review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.
**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified customs specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.
1.0.1 marks it unreviewed. The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| README.md | 4,007 |
| impl/python.py | 2,076 |
| impl/rust.rs | 2,874 |
| impl/typescript.ts | 1,973 |
| vectors.json | 7,983 |