WritingCommerce
Put customs data on the order line, not on the shipment
A playbook for carrying HS code, origin, declared value and description on every order line, so the duty quote, invoice and label are generated, never typed.
A customer in Dhaka orders a 20 W USB-C wall charger and two cotton t-shirts from a US marketplace listing, pays 64.50 USD for the goods plus the duty shown at checkout, and ten days later the parcel reaches the border declared as "Gift" with a value of 10 USD, because the packer typed it from the listing title. The outcome of this playbook is that the declaration is never typed. Every order line carries its own HS code, country of origin, declared value and plain description from the moment the order is placed, and the duty quote, the invoice and the label are generated from those fields. Customs data is a property of the order line, not of the shipment, and an order whose lines cannot be declared should not be placeable at all.
Store customs fields on the order line, not on the shipment
The shipment is created days after the order, once the item has been procured from the seller and has landed at the warehouse. By then the listing may have changed price, the seller may have sent a substitute variant, and the line may be boxed with lines from two other orders by the same customer. The order line is the only record that is stable from checkout to delivery and agrees with what the customer paid, so it owns the declaration, and a shipment only groups the lines it carries. I have built cross-border procurement from Amazon, Alibaba, AliExpress and 1688 with DHL, FedEx, UPS and USPS behind it, and this is the rule I would set before the first order.
Add these columns to the order line, and two to the order itself: destination_country and incoterm (DDP when you collected duty at checkout, DAP when the recipient pays on delivery), both fixed at checkout.
| Field | Value | Set when |
|---|---|---|
hs_code | Six-digit harmonised code, for example 610910 for a cotton t-shirt | Item enters the cart |
hs_code_destination | The destination's longer code, where the tariff has one | Checkout, once the country is fixed |
origin_country | ISO alpha-2 code such as BD, never blank and never XX | Cart, from the listing or from review |
customs_description | Plain words, built as in step 4 | Cart, from the classification |
declared_value_minor, declared_currency | Goods price charged per unit, for example 1250 and USD | Checkout, from the charged price |
unit_weight_g | From the listing, corrected at receiving | Cart, corrected at receiving |
restricted_flags | lithium_battery, liquid, aerosol or none | Cart, from the classification |
classification_source, classification_confidence | rule, model or manual, and a score from 0 to 1 | Cart |
flowchart TD L[Listing] --> O[Order line customs fields]:::accent O --> Q[Duty quote at checkout] O --> I[Commercial invoice] O --> B[Carrier label] S[Shipment groups lines] --> I S --> B
You know it worked when the commercial invoice renders from the order tables alone, with no join to a shipment and no field a packer typed.
Classify to six digits when the item enters the cart
The first six digits of an HS code are shared across countries; the digits after them belong to the destination's own tariff. Six digits are enough to quote duty at checkout and to know whether the item is restricted, and because the destination is fixed at checkout, the longer code can be filled in there. Classifying at the warehouse instead means the duty you showed the customer was a guess, and that a line a person needs to look at is discovered after you have already bought the item from the seller and can no longer return it cheaply.
Run a rule on the marketplace category first: a listing in a phone chargers category maps to 850440 through a table you maintain. When no rule matches, a model on the title and attributes returns a code and a confidence, and the confidence decides what happens next. At 0.9 or above the code applies; between 0.6 and 0.9 it applies provisionally and opens a review case that must close before procurement may buy the item; below 0.6 the cart holds the line and tells the customer the item needs checking first. Store the source and the confidence on the line, so a wrong code can be traced to the rule or model version that produced it.
flowchart TD
L[Listing] --> C[Category rule]
C -->|rule found| A[Apply code]
C -->|no rule| M[Title model]
M --> D{Confidence}
D -->|high| A
D -->|middle| R[Review before procurement]:::accent
D -->|low| H[Hold the line]
A --> O[Order line]
R --> OYou know it worked when every review case was opened before procurement and none was raised by a packer holding the item.
Declare the price you charged, in the currency you charged it
Three prices exist for a procured item: the listing price when it entered the cart, the price you paid the seller at procurement, and the price you charged the customer. Only the last one appears on the receipt the recipient can show, and only it survives a seller price change between cart and purchase. When the invoice at the border and the receipt in the customer's hand disagree, the officer has a reason to query the parcel; when they match to the minor unit, there is nothing to query. Destinations also differ on whether freight and insurance count in the dutiable base, so keep freight and your service fee as their own lines and let the invoice generator include or exclude them per destination.
Allocate any order-level discount across the lines before you store the charged price, so the line values sum to the goods subtotal. Then build invoice lines from the order lines, and refuse to build them when the sum does not hold.
// simplified: invoice lines come from order lines, never from the parcel
type OrderLine = {
id: string;
hsCode: string;
originCountry: string;
customsDescription: string;
quantity: number;
unitPriceChargedMinor: number;
currency: string;
unitWeightG: number;
};
export function invoiceLines(lines: OrderLine[], goodsTotalMinor: number) {
const out = lines.map((l) => ({
hsCode: l.hsCode,
origin: l.originCountry,
description: l.customsDescription,
quantity: l.quantity,
unitValueMinor: l.unitPriceChargedMinor,
lineValueMinor: l.unitPriceChargedMinor * l.quantity,
currency: l.currency,
netWeightG: l.unitWeightG * l.quantity,
}));
const declared = out.reduce((sum, x) => sum + x.lineValueMinor, 0);
if (declared !== goodsTotalMinor) {
throw new Error(`declared ${declared}, charged ${goodsTotalMinor}`);
}
return out;
}You know it worked when the invoice total equals the goods subtotal on the receipt, to the minor unit, on every order, and a mismatch raises an exception rather than a rounding note.
Write the description a border officer can read, not the listing title
A listing title is written to win a search: "2026 NEW Premium Breathable Summer Tee Men Sale". An officer needs three things from a description: what the item is, what it is made of, and how many there are. "Gift", "Accessories", "Parts" or the bare HS code are the words that invite an inspection, because they say nothing. Carriers also truncate the description at different lengths, so a long title is cut at a point you did not choose, sometimes before the noun.
Generate the description from the classification and the listing attributes, with a template per code: material, item, use, count. 610910 with the attribute cotton and a quantity of 2 becomes "Cotton t shirt, mens, 2 pcs"; 850440 with 20 W becomes "USB wall charger, 20 W, 1 pc". Enforce four rules on the result: at least three words; one of them a noun from your classification table; nothing from a stoplist (gift, sample, parts, accessories, misc, new, sale, premium, any exclamation mark); and a length cap below every carrier limit you have confirmed in that integration. A reviewer may edit the generated text, and a packer may not.
You know it worked when you read ten invoices without the listings and can say for each line what the item is, what it is made of and how many there are.
Refuse the label until every line validates
Here is the failure the gate exists for. Order 48213 has three lines, and line 2 is a 20000 mAh power bank that the model classified as 850440, a charger, at a confidence of 0.72, so a review case opened. The reviewer was away for the weekend, and because validation was in warn mode, procurement bought the item on Friday. On Tuesday the packer printed a label with the description "USB charger" and no dangerous goods flag.
At the carrier's origin hub the x-ray showed a loose lithium battery, the carrier refused the parcel and returned it, and the customer's tracking page read "returned to shipper" on day six, with the two t-shirts stuck in the same box. You paid the return, re-shipped the t-shirts, and refunded a power bank you now own. With the gate in place the open review case fails procurement on Friday, and the lithium_battery flag against a standard air service fails the label, so the order holds with a message to the customer and nothing is bought or shipped.
Run the checks below at the point named, and block rather than warn at each one: a warning is read by nobody at the packing bench, and a hold with the failing rule attached is read by the person who can fix it.
| Check | Fails when | Where it blocks |
|---|---|---|
hs_code | Not six digits, or absent from your code table | Cart |
origin_country | Blank, XX, or not a country code | Checkout |
customs_description | Under three words, on the stoplist, or equal to the listing title | Checkout |
| Declared value | Zero, or lines do not sum to the goods total charged | Checkout |
unit_weight_g | Zero, or further from the received weight than the tolerance you set | Receiving |
| Review case | Still open | Procurement, and again at the label |
restricted_flags | A flag the chosen carrier service does not accept | Checkout, and again at the label |
Make the label call read the validator's result, never the packer's screen, and have a failure open a case that names the line and the rule. You know it worked when a carrier hold for a missing or wrong declaration is traced to a rule you can change, and never to something a packer typed.
Build the columns and the gate first: add the customs fields to the order line, make the label call read from them and refuse on any failure, and backfill the fields for in-flight orders from whatever the packers have been typing. The classifier and the review queue come second; until they exist a reviewer fills the fields by hand, which is still a declaration produced once and read three times, not typed at the bench.
Before the first cross-border order ships
- Every order line has an HS code, origin country, description, declared value and weight before checkout completes
- The commercial invoice renders from order lines alone, with no field typed by a packer
- Line declared values sum to the goods total charged, after discounts are allocated per line
- A review case opened by low classification confidence blocks procurement until it closes
- The label call is refused when any line fails validation, and the parcel is held with the rule named
- Restricted flags are checked against the chosen carrier service at checkout and again at the label
- The description stoplist rejects gift, sample, parts and accessories on their own