Customs declarations
Regimes
Dossier.Regime |
System | Message | XML envelope |
|---|---|---|---|
IM — Import |
IDMS | IE415 | IE415B / IIE415B |
EX — Export |
AES | CC515 | CC515C |
T1 / T2 — Transit |
NCTS | CC015C | CC015C |
The regime is set at upload time (manual) or via declaration_type (ingestion) and determines the entire downstream processing: AI extraction prompt, transport cost calculation target value, XML generator used, and declaration type options on the Declare page.
Declaration types
IM — Import
| Field | Options |
|---|---|
DeclarationType |
IM |
AdditionalDeclarationType |
A — Standard, D — Simplified, E — Simplified (warehouse), X — Pre-arrival |
UseH2B = false (H1B) |
Standard import, procedure 40 |
UseH2B = true (H2B) |
Customs warehouse entry, procedure 71 — adds Warehouse element to XML |
EX — Export
| Field | Options |
|---|---|
DeclarationType |
EX |
AdditionalDeclarationType |
A — Standard, C — Simplified (authorised location), D — Simplified (standard), F — Pre-lodged |
| Procedure codes | 10 — Standard export, 21 — Re-export from warehouse, 23 — Temporary admission re-export |
Dossier workflow
Manual path (file upload)
flowchart TD
UP(["Upload + select regime"])
UP -->|PDF — IM / EX| B["ProcessingWorker"]
UP -->|PDF — T1 / T2| BT["ProcessingWorker"]
UP -->|XLSX| XP["XlsxProcessor\ndirect parse"]
subgraph imex["IM / EX extraction"]
B --> ADI["Azure Document Intelligence\nOCR / layout"]
ADI -->|OCR text| CL["Azure OpenAI\nclassify_document"]
CL -->|invoice / packing_list| EX["Azure OpenAI\nextract_invoice / extract_packing_list"]
end
subgraph transit["Transit extraction"]
BT --> TADI["Azure Document Intelligence\nOCR / layout"]
TADI -->|OCR text| TEX["Azure OpenAI\nextract_transit"]
end
EX -->|status=Review| C["Review page\ncorrect extracted data"]
TEX -->|status=Review| C
XP -->|status=Review| C
EX -->|status=Failed| F
TEX -->|status=Failed| F
C --> D["TransportCosts page\nadd freight / insurance"]
D --> E["Declare page\nreferences & contacts"]
E -->|IM| G1["IE415BXmlGenerator\nIE415"]
E -->|EX| G2["CC515CXmlGenerator\nCC515C"]
E -->|T1/T2| G3["CC015CXmlGenerator\nCC015C"]
G1 & G2 & G3 --> H(["Download / submit\nto customs"])
F(["Failed\nsee LastErrorMessage"]) -->|Retry| B
F -->|Retry| BT
Ingestion path (API / ServiceBus)
flowchart TD
API(["POST /api/declarations\n(X-Api-Key or Bearer)"])
SB(["ServiceBus\ncanonical queue"])
API --> IS["ICanonicalIngestionService"]
SB --> IS
IS --> RC{"Resolve client"}
RC -->|ClientCode match| CL[(Existing client)]
RC -->|EORI match| CL
RC -->|no match| NC["Auto-create client\nIsCustomsClient=true"]
NC --> CL
IS -->|declaration_type=IM| A1["CanonicalToImportAdapter\n→ CorrectedImportData"]
IS -->|declaration_type=EX| A2["CanonicalToExportAdapter\n→ CorrectedExportData"]
IS -->|declaration_type=T1/T2| A3["CanonicalToTransitAdapter\n→ CorrectedTransitData"]
A1 & A2 & A3 --> D[(Dossier\nStatus=Review\nSource=ApiIngestion\nor ServiceBusIngestion)]
Dossier statuses
The legal transitions are the Dossier.AllowedTransitions table (Dossier.TransitionTo throws on
anything else; a self-transition is always allowed). This diagram is derived from it — if they
disagree, the code is right and this needs updating.
stateDiagram-v2
[*] --> Queued : manual upload / SharePoint sync
[*] --> Review : structured ingestion (API, Service Bus, declaration file)
Queued --> Processing : worker claims a job
Processing --> Review : extraction ok
Processing --> Failed : extraction failed
Review --> Processing : next document of the same batch
Failed --> Queued : Retry / Reprocess
Review --> Approved : declarant approves
Review --> Ready : declare step (transit)
Approved --> Ready : declare step
Approved --> Submitted : submit
Ready --> Submitted : submit
Submitted --> Approved : reopen
Ready --> Approved : reopen
Submitted --> [*] : the app hears nothing further
| Status | Enum value | Meaning |
|---|---|---|
Draft |
0 | In the enum and produced by no live path (the Skechers sync inserts it transiently before Queued) |
Queued |
6 | One ExtractionJobs row per document waiting in the durable SQL queue |
Processing |
1 | A worker holds a claim on one of the dossier's jobs |
Review |
2 | Extraction complete, or data arrived structured; the declarant reviews and corrects |
Approved |
3 | Reviewed and signed off; goods breakdown, transport costs and declare steps follow |
Ready |
7 | Declaration details complete; locked for editing; may be submitted |
Submitted |
4 | Outbox row written in the same transaction; OutboxDispatcher hands it to Service Bus. Descartes returns no acceptance, refusal or LRN — what happened next is only visible in Descartes |
Failed |
5 | Extraction or endpoint error — see LastErrorMessage; Retry requeues |
Ingested dossiers start at Review (no extraction; data comes from the canonical payload) and are
RequiresReview = true. A transit declaration carrying auto_dispatch is approved and submitted by
AutoDispatchService without a declarant, never forcing past schema validation.
Transport cost division
Applies to both IM and EX. Handled on the /Dossiers/{id}/TransportCosts page.
| Regime | Target value | What to enter |
|---|---|---|
| IM | CIF — cost + insurance + freight to EU border | International freight (from origin to BE) |
| EX | FOB — free on board at EU exit point | Internal transport cost to EU border |
Distribution is proportional by line value across all goods items. The incoterm group (E/F, C, DAP) determines whether costs are added or deducted and which formula applies:
- E & F terms (EXW, FOB, FCA…): add AK (sender → EU border), add CA (EU border → destination), subtract FA (declaration cost)
- C terms (CIF, CFR, CPT…): no AK, add CA, subtract FA
- DAP / DDP / DPU: deduct BA (inside-EU portion), add CA, subtract FA
Air (IM): the split between outside-EU and inside-EU freight comes from the Annex 23-01 zone table (Regulation 2015/2447), by country of dispatch, and allocation is by net weight. The share is the part of the flight that lies outside the EU, so it depends on where the goods left from, never on which Belgian airport they land at. Three countries are so large that the share depends on the airport rather than the country — the United States (New York 70 %, Los Angeles 78 %, Anchorage 89 %), Canada and Russia (Moscow 30 %, Vladivostok 83 %) — so for those the transport-costs page asks for the airport of departure, and a declaration cannot be generated without it. For an airport not in the list the regulation says to take the nearest listed one. A dispatch country the table does not list at all is refused rather than divided at a share nobody chose.
What the generator will not invent. A declaration is refused, with the gap named on the Details page, when a goods item has no country of origin, a non-euro invoice has no exchange rate, the shipment has no package type or (H1B) no package count, the client record has no country, or an air shipment's zone cannot be resolved. Each of these used to fall back to a plausible value (the dispatch country, a rate of 1, bins, one package, Belgium, 0 % outside the EU).
XML generation (IM / CC415)
Implementation note: in code this message is generated through the IE415B flow for IDMS import declarations.
Two message sub-types driven by CustomsDeclaration.UseH2B:
| CC415_H1B | CC415_H2B | |
|---|---|---|
| Procedure | 40 | 71 |
| Trader | Exporter |
Seller |
| Warehouse element | — | ✅ |
| SupportingDocument 4006 | — | ✅ (AGP licence) |
HS code format
| Regime | Digits | Split for XML |
|---|---|---|
| IM / EX | 10 digits (no dots) | 1–6 → harmonizedSystemSubheadingCode7–8 → combinedNomenclatureCode9–10 → taricCode |
| T1 / T2 | 8 digits (no dots) | 1–6 → harmonizedSystemSubheadingCode7–8 → combinedNomenclatureCode |
Transit does not use taricCode.
Country resolution
Country names from AI extractions are resolved to ISO2 via the Tarbel GeographicalArea table. Unknown names (e.g. TURKIYE, USA) are resolved via the CountryAliases section in appsettings.json — see configuration.md.
XML generation (EX / CC515C)
Generated by CC515CXmlGenerator using CorrectedExportData. The SMF wrapper uses System=AES, Module=EXPORT, Identity=CC515C.
Key differences from CC415 (import):
- Root element: CC515C within the standard Descartes SMF envelope
- ExportOperation instead of ImportOperation
- Exporter is mandatory with full address
- ExitOffice (customs office of exit) — from CorrectedExportData.ExitCustomsOffice
- Route: ordered list of transit country codes
- No Warehouse element
- AuthorisedLocation element with typeOfLocation / qualifier / UNLocode
CorrectedExportData fields are populated either by:
- Manual path: user fills in review/declare pages (ReviewExport, DeclareExport)
- Ingestion path: CanonicalToExportAdapter maps fields from the CanonicalDeclaration
XML generation (T1/T2 / CC015C) — groupage
CC015CXmlGenerator supports two operating modes selected automatically:
| Mode | Trigger | Behaviour |
|---|---|---|
| Simple (PDF path) | No TransitGoodsItem.HouseConsignmentSequence set |
Auto-increments house consignment numbers from 1; unchanged from original |
| Groupage (ingestion path) | Any goods item has HouseConsignmentSequence set |
Uses Navision/Qargo sequence numbers as-is; emits referenceNumberUCR and Consignee per house consignment from CorrectedTransitData.HouseConsignments |
This ensures Qargo T2 messages (where HC sequence numbers are Navision shipment line IDs, not sequential) produce correct XML without affecting the existing PDF extraction flow.
SMF schema validation
When Descartes wrapper validation is enabled (Descartes:Smf:ValidateXsd=true), generated SMF XML is validated against SMF.xsd before returning/submitting.
Required settings:
- Descartes:Smf:ValidateXsd
- Descartes:Smf:XsdFolder
In Docker images built from this repo, XSD files are bundled under /app/schemas/smf, and Docker defaults set:
- Descartes__Smf__ValidateXsd=true
- Descartes__Smf__XsdFolder=/app/schemas/smf