TaricHive — Multi-Country Tariff Mapping: Gap Analysis & Remediation Plan
Analysis date: 2026-07-14. Sources inspected: TaricHive codebase + live SQL database, D:\Repo\taric-opendata\downloads, official tariff UIs (Tarbel, Tolltariffen, Tares), and the public Taric Support application bundle (behavioural inference only — no protected assets copied).
1. Executive Summary
Overall health: the architecture is right, the data is wrong for 3 of 5 priority countries.
TaricHive's core data model is a faithful TARIC entity model (GoodsNomenclature → Measure → MeasureComponent/Condition/Footnote), which is the correct normalisation target — it is the same shape Taric Support normalises everything into. The Belgian (Tarbel) import is reference-quality: fully structured duty components (32 EUR / HLT for 2204 21 06), conditions, footnotes, quotas and geo-group memberships all land correctly.
The headline problem — "specific duties (€/kg, €/hl, …) missing for NO, GB, CH" — is not a missing-data problem. Every one of those duties is already present in taric-opendata\downloads, and for GB and NO the amounts are even already in SQL. What is lost is the quantity dimension (unit + currency), each for a different reason:
| Country | Amount in DB? | What is lost | Root cause |
|---|---|---|---|
| GB | ✅ (977k monetary components) | Measurement unit (all NULL), currency stored as raw FK id ("277" instead of GBP), duty-expression id stored as raw FK ("32" instead of "01") | GbImporter never reads measure-measurements*, measure-monetary-units, measure-duty-expressions CSVs; hard-codes MeasurementUnitCode = NULL |
| NO | ✅ (284k components) | Unit (satsEnhet K/L/S = per kg/litre/piece) and implicit NOK currency discarded; 80% of Norwegian duties (233,110 of 291k rates) are kr/kg |
NoImporter.ImportDutiesAsync parses satsEnhet then throws it away, writing a bogus expression id "02" |
| CH | ✅ (255k components) | Everything except the number: weight=GROSS_WGHT, factor=100, CHF currency, rate texts (quota remarks) dropped. Nearly all Swiss duties are CHF/100 kg gross |
ChImporter maps every rate to expression "01" (ad-valorem) — line 229 is literally typeCode == "NT" ? "01" : "01" |
| TR | ✅ nomenclature only (19,719 GTİP codes imported 2026-07-14: 15,712 base rates, 3,832 supplementary units, 15,696 EN backfills, 116 notes) | No measures at all — TGTC carries only the WTO bound (474) rate; applied duties per country group (incl. €/100 kg specific lists) live in the Import Regime Decree annexes, which are not in taric-opendata | Missing source dataset |
| BE/EU | ✅ | BE complete. EU: only 442 monetary components in the whole EU set and 0 measurement units — the EU xlsx duty-string parser silently skips Meursing/EA/AD S/Z and any segment it can't parse | Parser coverage |
The final symptom is compounded in the display layer: TariffHelpers.FormatDuty decides percent vs. specific solely by MonetaryUnitCode != null. Since NO/CH never store one, every Norwegian kr/kg duty and every Swiss CHF/100kg duty currently renders as a percentage (e.g. Tolltariffen's "kr 25,71 per kg" for 0207 poultry shows as "25.71 %"); GB shows "277 26.00" instead of "£26.00 / hl".
The three country fixes are cheap (each is a contained importer change + re-import; the SQL schema already has all needed columns), and they should be accompanied by one structural decision: normalise every source to TARIC duty-expression semantics at import time (one convention, one formatter, one calculator), which is exactly the strategy Taric Support uses.
2. Country Comparison — Data Model
2.1 Source dataset anatomy
| Aspect | BE (Tarbel) | GB (HMRC CSV) | CH (BAZG/Tares) | NO (Toll.no) | TR (TGTC) |
|---|---|---|---|---|---|
| Format | Full TARIC XML (initial + deltas) | ~45 normalised CSVs, TARIC clone with trackedmodel_ptr_id surrogate keys |
Proprietary XML in zips (TariffMasterData_v6, 200 MB) |
Per-entity XML/JSON/XSD dumps (~30 datasets) | Per-chapter legacy .xls |
| Code length | 10-digit TARIC (+CN8) | 10-digit (UK Global Tariff, TARIC-derived) | 8-digit tariff number (dotted 2204.2150) + statistical keys |
8-digit varenummer | 12-digit GTİP (digits 1–8 = HS6+CN) |
| Hierarchy | Indents + product-line suffix | commodity-indents, suffix, commodity-indent-nodes |
TariffsTree_v1 (TN2/TN4/TN6/TN8 levels) |
tolltariffstruktur + customstariffstructure (EN) |
Indent level from description dashes |
| Duty model | Structured components: expression id + amount + monetary + unit + qualifier | Structured: measure-components → FK to duty-expression, measurement (unit+qualifier), monetary unit |
<rate>: value + type (NT/PR/FL/SP) + weight (GROSS_WGHT) + factor (100) + country group + optional per-rate text |
<sats>: satsVerdi + satsEnhet (P=%, K=kr/kg, L=kr/litre, S=kr/piece), NOK implicit |
Single "474" base rate column, always numeric % |
| Geo model | TARIC areas + memberships | geo-areas, geo-area-memberships, per-measure measure-excluded-geo-areas |
Numeric country groups (100000=normal, 100001=EU/EFTA…) in CountryCodes_v3 |
landgruppe agreement codes (TAL, TEU, TGB…), members in medlemsland |
None (national tariff = MFN column only) |
| Conditions/certificates | Full TARIC conditions + certificates | Full (incl. measure-condition-components — duty per condition path) |
Permits via controlCode/declarationRule blocks |
Restrictions in innfoerselsrestriksjon/utfoerselsrestriksjon |
n/a |
| Quotas | Quota order numbers, definitions, balances | 6 quota CSVs (definitions, associations, balance events, blockings, suspensions, order-number origins) | Quota numbers referenced in rate texts ("Zollkontingent Nr. 115") | tollkvote |
n/a |
| VAT / excise | BBT/BTW/BAO/BAS/BUM/EVH measure types (national extension of TARIC) | additional-code-descriptions VAT/excise types |
In master data (privilege codes / gebCode) |
innfoerselsavgift (VAT + excise per code) |
n/a |
| Agricultural | Meursing (EU), EA/AD S/Z components | Same TARIC mechanism | Included in specific rates | raavaretollavgiftssats (RÅK raw-material duties) |
n/a |
| Seasonal | Measure validity dates | Validity dates | periodFrom/periodTo (01.01.–31.12.) per code and per rate period |
Seasonal rates as dated sats rows |
n/a |
| Supplementary units | Measure types 109/110 | Same | Statistical keys + per-code units | enhet + annenEnhet on varenummer |
ÖLÇÜ BİRİMİ column (Baş, Adet, kg N…) |
| Exchange rates | n/a (EUR) | n/a (GBP) | n/a (CHF, but rates in Tares docs) | valutakurs + valutakurs_historisk |
n/a |
| Legal acts | TARIC regulations (+ CELEX via EurLex import) | regulations CSV with URLs |
Rate texts reference ordinances | henvisning (references) |
Decree references only |
2.2 Concept classification
- Universal (already abstracted correctly): commodity code + validity window, measure {goods, origin scope, type, dates}, duty amount, footnotes, descriptions per language.
- Universal but currently broken in 3 sources: the quantity dimension of a duty term —
(amount, currency, unit, qualifier, expression-kind). Every country expresses it; only BE/SE/NL imports keep it. - Country-specific — keep as optional entities: Meursing/additional codes (EU/GB), statistical keys (CH), Import Regime lists (TR), RÅK (NO), national tax measure types (BE alpha codes), ATaR/BTI datasets.
- Needs new abstraction: geo-group membership with per-measure exclusions (exists for EU/BE/SE, missing for GB/NO/CH — see §4), exchange rates (NO source exists; needed to display CHF/NOK duties in EUR like Taric Support's output-currency feature).
- Sentinels to normalise centrally:
4712-12-31(GB/TARIC end-of-time),2999-12-31(CH),#NA(GB),0,00decimal comma (NO), dotted codes (CH/US/TR).
3. Reverse-Engineering Report — Taric Support
Method: analysed the publicly served application bundle (app.20260508.075508.js) plus observable UI behaviour. Server code is not visible; inferences are marked.
Coverage: supported trade tariffs are exactly ['EU','DE','BE','NL','FR','GB','CH','SE','IT','US']. Taric Support has no Norway and no Turkey — TaricHive is already ahead there. Permissions gate each tariff (trade_tariff_*).
Architecture — everything is normalised server-side; the client is thin:
- Duty rates arrive pre-grouped:
groupedDuties[] { measureType, dutyRate (display string), dutyRatesWithMeasures[] { dutyRate, measures[] } }— group by measure type, then by identical duty string, then the list of country-scoped measures under each. This matches the Tarbel-style grouping TaricHive already does inMeasureService. - Duty strings are composed on the server; the client
formatDutyfilter is purely cosmetic (superscript$x, subscript@x, line breaks). ⇒ One server-side formatter handles all 10 tariffs — strong evidence they normalise all sources into a single duty representation. - Calculation is a server round-trip in two steps:
GET /goods/{code}/units?country=&movement=&referencecountry=&date=&preferencecode=&additionalcodes=…returns which quantity inputs the measures need (units/qualifiers). The UI renders one input per required unit.calculateDutyposts quantities serialised as"<amount> <unitCode> [<qualifierCode>]"plus certificates, additional codes, order number, preference code. ⇒ The calculator is driven by the measure components' units, not by hard-coded fields.- Origin resolution (
hasCountryfilter, client-side): each measure carriesregion {code, type}andexcluded_regions[]; acountryData.regionsmap (group code → member ISO list) expands groups; type"1"= group ⇒ check membership minus exclusions, else direct code compare. ⇒ Group membership + measure-level exclusions are first-class; TaricHive's origin filter (GeographicalAreaCode == "1011" || == country) cannot resolve group-scoped measures for GB/NO/CH because memberships were never imported. - Per-country transformations observed:
- CH: separate route
/tares-measures/{code}; data valid only from 2022-01-01; default region group200000, EU group100001; dedicatedtares-statistical-keysendpoint (the 8-digit code + statistical key = declarable unit); date picker min-date per country. - GB: monetary unit forced to GBP for dates after 2021-01-01;
not_up_to_datewarning when the requested date ≠ dataset date; preference-code filtering applied only for GB. - NL/BE/DE/FR national layers: verbruiksbelasting warning (NL), national additional codes (BE), German excise codes (EZT), France area-of-application (DOM) — national taxes are additive layers on the EU tariff, exactly TaricHive's BE alpha measure types.
- EU: Meursing modal for additional-code resolution; restitutions (export refunds) as separate concept.
- Output currency selector — duties stored in native currency, converted for display.
Implication for TaricHive: the winning pattern is normalise at import into TARIC semantics; keep one formatter, one calculator, one origin-resolver; layer country quirks as optional data (statistical keys, national additional codes), not as code paths in the display layer.
4. Missing Imports — exact files and elements
4.1 GB (downloads/gb, all files already present)
| File | Status | Consequence |
|---|---|---|
measure-duty-expressions |
Not read | Components store raw FK ("32") in DutyExpressionId; formatter can't recognise min/max/+ semantics |
measure-measurements, measure-measurements-units, measure-measurement-qualifiers |
Not read; MeasurementUnitCode hard-coded DBNull (GbImporter.cs:319) |
All units lost (verified: 0 of 2.43M GB components have a unit). Chain works: component 543682 → measurement 81438 → unit DTN (100 kg) + qualifier |
measure-monetary-units |
Not resolved — raw int stored ("277" = GBP, "259" = XEU) | Currency displays as a number |
measure-condition-components |
Whole file ignored | Condition-path duty amounts (e.g. reduced duty when certificate presented) lost — MeasureCondition.ConditionDutyAmount comes only from the parent row and its units aren't read either |
geo-area-memberships |
Ignored | Origin-based lookup can't expand groups (e.g. GSP) |
measure-excluded-geo-areas |
Ignored (no entity exists) | Measures apply to excluded countries incorrectly |
quota-definitions/-associations/-balance-events/-blockings/-suspensions/-order-number-origins |
Ignored | No GB quotas (table has only BE+SE rows) |
certificate-descriptions, footnote-types descriptions |
Partially ignored | GB certificates have no description text |
commodity-origins, commodity-successors |
Ignored | Acceptable (history navigation only) |
4.2 NO (downloads/no)
| File | Status | Content |
|---|---|---|
tollavgiftssats → satsEnhet + implicit NOK |
Parsed then discarded | K=per kg (233,110 rates!), L=per litre, S=per piece, P=% |
varenummer → enhet, annenEnhet |
Ignored | Statistical + supplementary units (e.g. 2204: Kg + Litre) |
medlemsland |
Ignored | Country → landgruppe membership (needed for origin filter) |
tollkvote |
Ignored | Norwegian tariff quotas |
raavaretollavgiftssats |
Ignored | RÅK agricultural raw-material duties (Norway's Meursing analogue) |
tilleggstollavgiftssats |
Ignored | Additional/safeguard duties |
innfoerselsavgift / utfoerselsavgift |
Ignored | Import VAT + excise per code / export duties |
innfoerselsrestriksjon / utfoerselsrestriksjon |
Ignored | Restrictions & controls (the whole category is empty for NO) |
valutakurs, valutakurs_historisk |
Ignored | Official exchange rates — free solution for output-currency conversion |
boundtariffconcessions, ratetradeagreements, henvisning, lettelse |
Ignored | WTO bound rates, FTA rate metadata, references, reliefs |
4.3 CH (downloads/ch)
| Element | Status | Content |
|---|---|---|
rate/weight, rate/factor |
Dropped | GROSS_WGHT + 100 = "CHF per 100 kg gross" — the defining property of the Swiss tariff |
rate/type semantic |
Collapsed (NT→"01" : "01") |
NT=normal, PR=preferential, FL=flat, SP=special — should map to measure types 103/142 analogues; currently NT/PR leak into the UI's "National taxes" bucket because MeasureService treats alpha type codes as Belgian national taxes |
Per-rate textDe/Fr/It/En |
Dropped | Quota remarks ("Portwein, im Rahmen des präferenziellen Zollkontingentes Nr. 115" — the red text on Tares) |
commodityCode/periodFrom, periodTo |
Dropped | Seasonal validity (model already has PeriodFrom/To columns!) |
customsPrivilegeCode (tariffLine, code) |
Dropped | Privilege regimes (normal/diplomatic/…) |
controlCode / declarationRule |
Dropped | Permit requirements = CH restrictions & controls |
TariffsTree non-TN8 levels |
Skipped | No hierarchy/headings for CH (IndentNumber all NULL); detail-page tree can't render |
| Statistical keys | Not present in imports | Tares statistical keys (Taric Support has a dedicated endpoint) — check TariffBaseMasterData_v2.zip (currently completely unused) |
CountryCodes_v3 country→group mapping |
Only groups imported | Membership expansion impossible |
4.4 TR (downloads/tr + downloads/2025 TGTC)
tr-nomenclature.csv(15,712 duty rates, all plain numeric %) andtr-notes.csvare handled byTrImporterand are now in the live DB (imported 2026-07-14: 19,719 codes, 116 section/chapter notes).- The TGTC
.xlsset contains no specific duties — the "474 VERGİ HADDİ" column is the WTO bound rate. Turkey's applied duties (per country group, incl. €/100 kg specific components, e.g. wine additional fixed duties) live in the İthalat Rejimi Kararı (Import Regime Decree) annex lists, plus anti-dumping communiqués. That is a new source for taric-opendata (published as Excel annexes on ticaret.gov.tr / Resmî Gazete), not a mapping fix.
4.5 EU
- The xlsx duty-string parser (
TryParseDutySegment) recognises onlyNN %andNN CUR/unit[/qual]. Meursing (EA,AD S/Z,AD F/M),MIN/MAXwith compound sub-expressions, and reduction markers are silently skipped (only logged as a count). Verified effect: EU has 442 monetary components and 0 measurement units in SQL, while BE (Tarbel, same tariff!) has 1.06M — the EU import should either parse those segments into structured terms or be treated as descriptive-only where BE data is authoritative. - EU quotas/balances not imported (BE covers them for the BE view).
5. Database Improvements
The schema is largely sound — notably, no schema change is needed to fix GB/NO/CH duties; MeasureComponents already has MonetaryUnitCode, MeasurementUnitCode, MeasurementUnitQualifier.
- Duty expression convention (critical, data-level): one source stores "01/15/17" (EU), another raw FKs "32" (GB), another "01/02" with wrong semantics (NO/CH). Standardise on TARIC ids (
01% or amount,04+%,15MIN,17MAX,19/20+amount,12agricultural,25/27reduced agri) and enforce with a check constraint or lookup FK per source intoDutyExpressions(currently populated only for BE and SE — import GB'smeasure-duty-expressions, seed synthetic rows for NO/CH/EU). - New entity
MeasureExcludedGeographicalArea(MeasureId,AreaCode,AreaId?) — required for GB (file exists) and EU/BE (Tarbel has exclusions; verify the 015 record handling), and it is a load-bearing part of Taric Support'shasCountrylogic. - New entity
ExchangeRate(Source,CurrencyCode,Rate,ValidityStart/End) — fed by NOvalutakursand ECB reference rates; enables output-currency display for CHF/NOK/GBP duties. GeographicalAreaMembership: fine as-is (FK-based), but add a unique index(GroupId, MemberId, ValidityStart)and populate for GB/NO/CH.- Supplementary units:
GoodsNomenclature.Unitis currently a TR-only string. NO (enhet/annenEnhet) and CH (statistical keys) need the same concept — either widen toUnit+Unit2columns or (better) aNomenclatureUnitchild table(GoodsNomenclatureId, UnitCode, Kind: statistical|supplementary, ValidityStart/End). Keep TR's column as a legacy alias until migrated. Measure.CurrencyCodedefault per source is unnecessary — put the currency on each component (NOK/CHF/GBP) during import; NULL then always means ad-valorem, which restores the invariantFormatDutyrelies on.- Nullable-but-shouldn't-be:
Measure.Sourceis required (good); consider makingMeasure.MeasureTypeCodenon-null by seeding synthetic types (NO already does "103"; CH should map NT→"103", PR→"142", FL/SP→dedicated codes) — this also fixes the UI bucketing bug where CH NT/PR fall into "National taxes". - Indexes: lookups are covered. Two additions worth measuring:
MeasureComponents (MeasureId) INCLUDE (DutyExpressionId, DutyAmount, MonetaryUnitCode, MeasurementUnitCode, MeasurementUnitQualifier)to make the detail page a single index scan, andMeasures (Source, OrderNumber)for quota pages. - QuotaDefinitions is populated for BE/SE only — extend GB (6 CSVs) and NO (
tollkvote) into the same tables; the entity already fits.
6. Importer Improvements — target architecture
Current state: 13 self-contained importers each doing extract+transform+load with private conventions. The repeated defects (unit dropped here, FK unresolved there, different expression ids) are exactly what a shared normalisation stage prevents.
source files ──► Country adapter (ITariffSourceAdapter) ← only country-specific code
yields canonical DTOs
──► Normaliser (shared) ← code formats, date sentinels,
TARIC expression ids, unit mapping decimal commas, currency defaults
──► Validator (shared) ← invariants, fail loud
──► Bulk loader (shared, exists: TaricImporterBase)
──► Post-import verification (shared) ← per-source SQL assertions
Key contracts:
public interface ITariffSourceAdapter
{
string Source { get; }
IAsyncEnumerable<CanonicalNomenclature> ReadNomenclatureAsync(string dir, CancellationToken ct);
IAsyncEnumerable<CanonicalMeasure> ReadMeasuresAsync(string dir, CancellationToken ct);
IAsyncEnumerable<CanonicalGeoArea> ReadGeoAreasAsync(string dir, CancellationToken ct);
// optional capability interfaces for quotas, memberships, footnotes, units…
}
public sealed record CanonicalDutyTerm(
DutyTermKind Kind, // AdValorem, Specific, Minimum, Maximum, PlusAdValorem,
// PlusSpecific, Agricultural, NonComputable
decimal? Amount,
string? CurrencyCode, // ISO 4217 — "NOK", "CHF", "GBP", "EUR"
string? UnitCode, // TARIC units: KGM, LTR, HLT, DTN, NAR…
string? UnitQualifier, // e.g. "G" gross, "N" net
string? RawText = null); // original expression for NonComputable terms
The Validator is the piece that would have caught all three bugs on day one:
// Invariants per canonical measure component:
// 1. Specific => Amount, CurrencyCode and UnitCode all present.
// 2. AdValorem => CurrencyCode and UnitCode both null.
// 3. Kind maps to a known TARIC DutyExpressionId.
// 4. Source-level: >0.1% of components specific for CH/NO (they are mostly specific);
// GB: >25% of monetary components must have a unit; etc.
Post-import verification = a table of per-source SQL assertions run automatically after each import (counts by expression kind, % with units, orphan FK checks), logged and failing the run on violation.
Migration path: don't rewrite all 13 importers. Introduce the canonical layer, port GB, NO, CH onto it while fixing them (they're being rewritten anyway), leave BE/EU/NL/SE as-is (they already produce correct rows), port opportunistically later.
7. Duty Calculation Engine Design
Store structured terms, render text on demand — the current MeasureComponent row is the right storage (TARIC itself models duties as expression sequences); it just needs consistent semantics plus a typed layer on top:
public enum DutyTermKind
{
AdValorem, // 01 with no monetary unit → value * rate/100
Specific, // 01 with monetary unit → qty(unit) * amount
PlusAdValorem, // 04 → add
PlusSpecific, // 19, 20 → add
Minimum, // 15 → clamp lower
Maximum, // 17 → clamp upper
Agricultural, // 12 (EA), 25/27 reduced → resolve via Meursing/RÅK table
NonComputable // unparsed/national text → display only
}
public sealed record DutyTerm(DutyTermKind Kind, decimal? Amount,
string? Currency, string? Unit, string? Qualifier, string? RawText)
{
public static DutyTerm FromComponent(MeasureComponent c) => new(
Kind: c.DutyExpressionId switch
{
"01" or "07" => c.MonetaryUnitCode is null ? DutyTermKind.AdValorem : DutyTermKind.Specific,
"04" => DutyTermKind.PlusAdValorem,
"19" or "20" => DutyTermKind.PlusSpecific,
"15" => DutyTermKind.Minimum,
"17" => DutyTermKind.Maximum,
"12" or "25" or "27" => DutyTermKind.Agricultural,
_ => DutyTermKind.NonComputable
},
c.DutyAmount, c.MonetaryUnitCode, c.MeasurementUnitCode, c.MeasurementUnitQualifier, null);
}
Unit table, not string math — one lookup makes "€/100 kg", "£/hl", "CHF per 100 kg gross" all computable:
public sealed record UnitDefinition(string Code, string BaseUnit, decimal Factor);
// DTN = (KGM, 100), HLT = (LTR, 100), MIL = (NAR, 1000), KGM = (KGM, 1), LTR = (LTR, 1)…
// Qualifier G/B (gross) selects gross weight input instead of net.
Evaluation is an ordered fold with clamps (matches TARIC calculation rules and Taric Support's behaviour):
public static decimal? Evaluate(IReadOnlyList<DutyTerm> terms, DeclarationInput i)
{
decimal running = 0; decimal? floor = null, ceiling = null;
foreach (var t in terms)
{
switch (t.Kind)
{
case DutyTermKind.AdValorem or DutyTermKind.PlusAdValorem:
if (i.CustomsValue is not { } v || t.Amount is null) return null;
running += v * t.Amount.Value / 100m; break;
case DutyTermKind.Specific or DutyTermKind.PlusSpecific:
if (Quantity(i, t.Unit, t.Qualifier) is not { } q || t.Amount is null) return null;
running += q * t.Amount.Value; break; // q already divided by unit Factor
case DutyTermKind.Minimum: floor = Base(t, i); break;
case DutyTermKind.Maximum: ceiling = Base(t, i); break;
default: return null; // Agricultural/NonComputable → formula-only
}
}
if (floor is { } f && running < f) running = f;
if (ceiling is { } c && running > c) running = c;
return running;
}
Quantity inputs are derived from the measures (Taric Support's /units pattern): collect the distinct (Unit, Qualifier) pairs of the applicable measures' terms and ask the user for exactly those (net kg, gross kg, litres, items…). This automatically makes the calculator work for CH gross-weight duties and NO per-litre duties with zero country-specific UI code.
One formatter replaces TariffHelpers.FormatDuty + DutyCalculatorService.BuildFormula:
public static string Format(DutyTerm t) => t.Kind switch
{
DutyTermKind.AdValorem => $"{t.Amount:0.##} %",
DutyTermKind.Specific or DutyTermKind.PlusSpecific
=> $"{t.Amount:0.####} {t.Currency} / {UnitLabel(t.Unit, t.Qualifier)}", // "26 GBP / hl", "25 CHF / 100 kg gross"
DutyTermKind.Minimum => $"MIN {t.Amount:0.##} …",
DutyTermKind.Maximum => $"MAX {t.Amount:0.##} …",
DutyTermKind.Agricultural => "+ EA",
_ => t.RawText ?? "…"
};
Currency conversion for display (EUR view of CHF/NOK/GBP duties) uses the ExchangeRate table (§5.3).
8. Prioritised Roadmap
| # | Item | Priority | Effort | Notes |
|---|---|---|---|---|
| 1 | ~~GB: resolve duty-expression, unit, qualifier, currency FKs~~ Done 2026-07-15 | Critical | — | Verified: 2204210900 → 26 GBP/HLT |
| 2 | ~~NO: map satsEnhet → unit + NOK~~ Done 2026-07-15 |
Critical | — | 233k NOK/KGM components |
| 3 | ~~CH: Specific CHF/100kg gross + measure types + rate texts~~ Done 2026-07-15 | Critical | — | 100% CHF+DTN G; 207 quota-remark footnotes |
| 4 | ~~Single duty formatter + calculator on DutyTerm~~ Done 2026-07-15 | Critical | — | Core.Services.Duty; 37 unit tests; Calculate-duties tab live |
| 5 | ~~Run TR import~~ Done 2026-07-14 (19,719 codes) | Critical | — | |
| 6 | ~~Post-import verification suite~~ Done 2026-07-15 | High | — | ImportVerifier + verify command, gates every import |
| 7 | ~~Geo memberships GB/NO/CH + exclusions entity + origin filter~~ Done 2026-07-15 | High | — | GB 11.5k, CH 1.5k, NO 1.4k, NL added; EU+GB+BE exclusions |
| 8 | ~~GB measure-condition-components~~ Done 2026-07-15 |
High | — | First component backfills condition amount/units |
| 9 | ~~CH hierarchy TN2–TN8 + indents~~ Done 2026-07-15 | High | — | + statistical keys (SI/SE) with panel on the CH page |
| 10 | ~~Supplementary units + NomenclatureUnit table~~ Done 2026-07-15 |
High | — | statistical/supplementary rows for NO+TR |
| 11 | ~~Quotas: GB CSVs + NO tollkvote~~ Done 2026-07-15 |
Medium | — | GB order numbers + definitions; NO tollkvote w/ used volumes |
| 12 | ~~EU duty parser (real grammar + agricultural markers + exclusions)~~ Done 2026-07-15 | Medium | — | 7k monetary comps (was 442); EA/ADSZ → expr 12/21… |
| 13 | ~~NO VAT/excise + restrictions~~ Done 2026-07-15 | Medium | — | MV 25/15% VAT, excises; restriction types via MCAC=2 |
| 14 | ~~ExchangeRate table + ≈EUR display~~ Done 2026-07-15 | Medium | — | NO valutakurs (45 currencies), cross via NOK |
| 15 | ~~CH permits as restriction measures~~ Done 2026-07-15 | Medium | — | 10.5k P{authority} measures, CHP footnotes |
| 16 | ~~Canonical layer (SourceConventions + adapter contract) + auto verify/reconcile~~ Done 2026-07-15 | Medium | — | Full adapter port of existing importers = opportunistic |
| 17 | ~~TR applied duties (Import Regime Decree lists)~~ Done 2026-07-15 | Nice | — | 60k measures incl. compound MIN/MAX EUR duties; T1/T2 composition markers still skipped |
| 18 | NO RÅK, bound concessions, lettelse; GB commodity-origins/successors; CH privilege codes; TR T1/T2 composition tables + İGV (Karar 3351) |
Nice | 2–4 d | |
| 19 | ~~Import-state lifecycle marker + auto-heal~~ Done 2026-07-15 | High | — | "importing"/"complete" markers; crashed imports self-repair on next sync |
Estimated total to "all five priority countries correct and calculable": ~2–3 weeks of focused work, with the four Critical items delivering the user-visible fix in 2–4 days.
9. Concrete Code Recommendations
9.1 GbImporter — resolve the three FK chains
Extend the record + lookups:
private sealed class RMeasureComponent
{
[Name("component_measure_id")] public int ComponentMeasureId { get; set; }
[Name("duty_expression_id")] public int? DutyExpressionId { get; set; }
[Name("duty_amount")] public decimal? DutyAmount { get; set; }
[Name("monetary_unit_id")] public int? MonetaryUnitId { get; set; }
[Name("component_measurement_id")] public int? ComponentMeasurementId { get; set; } // NEW
}
private sealed class RDutyExpression
{
[Name("trackedmodel_ptr_id")] public int TrackedmodelPtrId { get; set; }
[Name("sid")] public int Sid { get; set; } // 1, 4, 15, 17, 19…
}
private sealed class RMeasurement
{
[Name("trackedmodel_ptr_id")] public int TrackedmodelPtrId { get; set; }
[Name("measurement_unit_id")] public int? MeasurementUnitId { get; set; }
[Name("measurement_unit_qualifier_id")] public int? MeasurementUnitQualifierId { get; set; }
}
// + RMonetaryUnit { TrackedmodelPtrId, Code }, RMeasurementUnit { TrackedmodelPtrId, Code },
// RMeasurementQualifier { TrackedmodelPtrId, Code }
// BuildLookups additions
foreach (var r in Csv<RDutyExpression>(dir, "measure-duty-expressions"))
l.DutyExprSidById[r.TrackedmodelPtrId] = r.Sid.ToString("00"); // 1 → "01" (TARIC ids)
foreach (var r in Csv<RMonetaryUnit>(dir, "measure-monetary-units"))
l.MonetaryCodeById[r.TrackedmodelPtrId] = r.Code; // 277 → "GBP"
foreach (var r in Csv<RMeasurementUnit>(dir, "measure-measurements-units"))
l.MeasurementUnitCodeById[r.TrackedmodelPtrId] = r.Code; // → "DTN", "HLT", "LTR"
foreach (var r in Csv<RMeasurementQualifier>(dir, "measure-measurement-qualifiers"))
l.QualifierCodeById[r.TrackedmodelPtrId] = r.Code;
foreach (var r in Csv<RMeasurement>(dir, "measure-measurements"))
l.MeasurementById[r.TrackedmodelPtrId] =
(r.MeasurementUnitId, r.MeasurementUnitQualifierId);
// ImportMeasureComponentsAsync — replace the DBNull lines
string? unitCode = null, qualCode = null;
if (row.ComponentMeasurementId is { } mid && l.MeasurementById.TryGetValue(mid, out var meas))
{
if (meas.UnitId is { } uid) l.MeasurementUnitCodeById.TryGetValue(uid, out unitCode);
if (meas.QualifierId is { } qid) l.QualifierCodeById.TryGetValue(qid, out qualCode);
}
r["DutyExpressionId"] = row.DutyExpressionId is { } de && l.DutyExprSidById.TryGetValue(de, out var sid)
? sid : (object?)row.DutyExpressionId?.ToString() ?? DBNull.Value;
r["DutyAmount"] = (object?)row.DutyAmount ?? DBNull.Value;
r["MeasurementUnitCode"] = (object?)unitCode ?? DBNull.Value;
r["MeasurementUnitQualifier"] = (object?)qualCode ?? DBNull.Value;
r["MonetaryUnitCode"] = row.MonetaryUnitId is { } mu && l.MonetaryCodeById.TryGetValue(mu, out var cur)
? cur : DBNull.Value;
Apply the same monetary/measurement resolution in ImportMeasureConditionsAsync and add a new ImportMeasureConditionComponentsAsync for measure-condition-components.
9.2 NoImporter — keep the unit, add the currency
// buffer the unit too
var pending = new List<(int sid, string amount, string unit)>(); // unit = satsEnhet
// phase-2 component insert
var cCols = new[] { "MeasureId", "DutyExpressionId", "DutyAmount",
"MonetaryUnitCode", "MeasurementUnitCode" };
...
var (exprId, monetary, unitCode) = unitStr switch
{
"P" => ("01", (string?)null, (string?)null), // ad valorem %
"K" => ("01", "NOK", "KGM"), // kr per kg
"L" => ("01", "NOK", "LTR"), // kr per litre
"S" => ("01", "NOK", "NAR"), // kr per piece (stk.)
_ => ("01", "NOK", null)
};
cRow["DutyExpressionId"] = exprId;
cRow["DutyAmount"] = ParseDecimalNo(amountStr);
cRow["MonetaryUnitCode"] = (object?)monetary ?? DBNull.Value;
cRow["MeasurementUnitCode"] = (object?)unitCode ?? DBNull.Value;
Also in ImportGoodsNomenclatureAsync, capture enhet/annenEnhet into the supplementary-unit storage (§5.5), and add medlemsland → GeographicalAreaMemberships.
9.3 ChImporter — specific duties, correct measure types, rate texts
case "rate" when currentCode != null:
var nav = NavAt(reader.ReadSubtree());
var weight = nav.SelectSingleNode("weight")?.Value; // "GROSS_WGHT"
var factor = nav.SelectSingleNode("factor")?.Value; // "100"
var rateText = nav.SelectSingleNode("textEn")?.Value; // quota remark, may be null
...
// Swiss rate semantics: value is CHF per <factor> kg (gross by default).
// factor 100 → DTN (100 kg); qualifier "G" marks gross weight.
string? unitCode = factor == "100" ? "DTN" : "KGM";
string? qualifier = weight == "GROSS_WGHT" ? "G" : null;
// NT = MFN (→ "103"), PR = preference (→ "142"), FL/SP keep dedicated codes
mRow["MeasureTypeCode"] = rateType switch
{ "NT" => "103", "PR" => "142", "FL" => "CHFL", "SP" => "CHSP", _ => rateType };
// component insert — a Swiss rate of 0 is a real duty-free specific rate, keep it
cRow["DutyExpressionId"] = "01";
cRow["DutyAmount"] = ParseDecimal(amountStr);
cRow["MonetaryUnitCode"] = "CHF";
cRow["MeasurementUnitCode"] = unitCode;
cRow["MeasurementUnitQualifier"] = (object?)qualifier ?? DBNull.Value;
Store non-empty rateText as a measure footnote (synthetic type "CHR") so the Tares quota remarks surface exactly like on tares.ch. Map the NT/PR change through MeasureService untouched — "103"/"142" already land in Third-country duties / Tariff preferences, which also empties the wrong "National taxes" bucket for CH.
9.4 Display/calculator — delete the guess
Replace TariffHelpers.FormatDuty and DutyCalculatorService.BuildFormula/TryCalculate with the shared DutyTerm model from §7 (one file in Core, unit-testable). The current "02"/"04" switch arms in DutyCalculatorService are dead wrong for every source (EU emits 15/17, NO's 02 was a fake) and disappear with this change.
9.5 Verification (run after each re-import)
-- Expect: CH ≈ 100 %, NO ≈ 80 %, GB ≥ 25 % of monetary components carrying units
SELECT m.Source,
COUNT(*) AS comps,
SUM(IIF(c.MonetaryUnitCode IS NOT NULL, 1, 0)) AS specific,
SUM(IIF(c.MonetaryUnitCode IS NOT NULL
AND c.MeasurementUnitCode IS NULL, 1, 0)) AS specific_missing_unit -- must be ~0
FROM MeasureComponents c JOIN Measures m ON m.Id = c.MeasureId
GROUP BY m.Source;
-- Spot checks (compare with official UIs):
-- GB 2204210900 erga omnes → 26.00 GBP / HLT (trade-tariff.service.gov.uk)
-- NO 22042101 landgruppe → 0.00 NOK / KGM, TAL (tolltariffen.toll.no)
-- NO 0207xxxx → 25.71 NOK / KGM
-- CH 22042150 grp 100000 → 25.00 CHF / DTN G (tares: "25.00 Fr. je 100 kg brutto")
-- CH 22042150 grp 100001 → 8.50 CHF / DTN G + quota remark footnote
9.6 Unit tests (xUnit sketch)
[Theory]
[InlineData("P", "01", null, null)]
[InlineData("K", "01", "NOK", "KGM")]
[InlineData("L", "01", "NOK", "LTR")]
[InlineData("S", "01", "NOK", "NAR")]
public void No_satsEnhet_maps_to_unit(string enhet, string expr, string? cur, string? unit) { … }
[Fact]
public void Ch_gross_100kg_rate_evaluates_per_100kg_gross()
{
var term = new DutyTerm(DutyTermKind.Specific, 25m, "CHF", "DTN", "G", null);
var result = DutyEvaluator.Evaluate([term], new DeclarationInput { GrossWeightKg = 300 });
Assert.Equal(75m, result); // 300 kg / 100 * 25 CHF
}
[Fact]
public void Gb_wine_formats_as_gbp_per_hl()
{
var c = new MeasureComponent { DutyExpressionId = "01", DutyAmount = 26m,
MonetaryUnitCode = "GBP", MeasurementUnitCode = "HLT" };
Assert.Equal("26 GBP / hl", DutyFormatter.Format(DutyTerm.FromComponent(c)));
}
Appendix — verified database state (2026-07-14)
| Source | Measures | Components | …with currency | …with unit | Distinct expr ids | Goods codes |
|---|---|---|---|---|---|---|
| BE | 2,480,509 | 2,258,205 | 1,046,655 | 1,063,447 | 32 | (EU nomenclature) |
| GB | 2,910,094 | 2,426,617 | 977,272 (raw FK ids) | 0 | 31 (raw FKs) | 71,131 |
| NO | 284,141 | 284,141 | 0 | 0 | 2 (fake) | 7,445 |
| CH | 255,073 | 255,073 | 0 | 0 | 1 (fake) | 7,511 |
| EU | 145,546 | 104,104 | 442 | 0 | 3 | 25,763 |
| NL | 2,536,582 | 2,306,426 | 1,046,764 | 1,059,770 | 31 | (EU nomenclature) |
| SE | 808,512 | 566,381 | 225,211 | 228,233 | 19 | (EU nomenclature) |
| TR | 0 (no measures source) | 0 | — | — | — | 19,719 (imported 2026-07-14) |
Quota definitions: BE 25,956 / SE 18,095 / others 0. Geo memberships: BE 9,114 / SE 4,892 / EU 2,708 / GB, NO, CH 0.