Skip to content

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,00 decimal 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:

  1. 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 in MeasureService.
  2. Duty strings are composed on the server; the client formatDuty filter 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.
  3. Calculation is a server round-trip in two steps:
  4. 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.
  5. calculateDuty posts 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.
  6. Origin resolution (hasCountry filter, client-side): each measure carries region {code, type} and excluded_regions[]; a countryData.regions map (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.
  7. Per-country transformations observed:
  8. CH: separate route /tares-measures/{code}; data valid only from 2022-01-01; default region group 200000, EU group 100001; dedicated tares-statistical-keys endpoint (the 8-digit code + statistical key = declarable unit); date picker min-date per country.
  9. GB: monetary unit forced to GBP for dates after 2021-01-01; not_up_to_date warning when the requested date ≠ dataset date; preference-code filtering applied only for GB.
  10. 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.
  11. EU: Meursing modal for additional-code resolution; restitutions (export refunds) as separate concept.
  12. 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
tollavgiftssatssatsEnhet + implicit NOK Parsed then discarded K=per kg (233,110 rates!), L=per litre, S=per piece, P=%
varenummerenhet, 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 %) and tr-notes.csv are handled by TrImporter and are now in the live DB (imported 2026-07-14: 19,719 codes, 116 section/chapter notes).
  • The TGTC .xls set 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 only NN % and NN CUR/unit[/qual]. Meursing (EA, AD S/Z, AD F/M), MIN/MAX with 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.

  1. 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 +%, 15 MIN, 17 MAX, 19/20 +amount, 12 agricultural, 25/27 reduced agri) and enforce with a check constraint or lookup FK per source into DutyExpressions (currently populated only for BE and SE — import GB's measure-duty-expressions, seed synthetic rows for NO/CH/EU).
  2. 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's hasCountry logic.
  3. New entity ExchangeRate (Source, CurrencyCode, Rate, ValidityStart/End) — fed by NO valutakurs and ECB reference rates; enables output-currency display for CHF/NOK/GBP duties.
  4. GeographicalAreaMembership: fine as-is (FK-based), but add a unique index (GroupId, MemberId, ValidityStart) and populate for GB/NO/CH.
  5. Supplementary units: GoodsNomenclature.Unit is currently a TR-only string. NO (enhet/annenEnhet) and CH (statistical keys) need the same concept — either widen to Unit+Unit2 columns or (better) a NomenclatureUnit child table (GoodsNomenclatureId, UnitCode, Kind: statistical|supplementary, ValidityStart/End). Keep TR's column as a legacy alias until migrated.
  6. Measure.CurrencyCode default per source is unnecessary — put the currency on each component (NOK/CHF/GBP) during import; NULL then always means ad-valorem, which restores the invariant FormatDuty relies on.
  7. Nullable-but-shouldn't-be: Measure.Source is required (good); consider making Measure.MeasureTypeCode non-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".
  8. 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, and Measures (Source, OrderNumber) for quota pages.
  9. 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 medlemslandGeographicalAreaMemberships.

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.