Assembling CBP 7501 line items from classified entries
Transforming a classified, duty-computed entry line into a filable CBP Form 7501 line record is where classification meets the wire format: each internal line item must become a tariff-line record carrying an HTSUS number, an entered value, a duty amount, a merchandise processing fee, a harbor maintenance fee, and a quantity in the unit of measure the schedule demands. Do it wrong and ACE rejects the entry summary — or worse, accepts an understated duty that surfaces months later as a liquidated-damages claim. The correct behavior is deterministic: read validated line items whose classification and duty are already settled, group them into 7501 tariff lines, compute the statutory fees with Decimal money and half-up rounding, and emit a structured line record that a transmission layer can serialize without further arithmetic. This page gives you a single runnable assembler that enforces that contract, plus the verification checklist and the grouping, rounding, and unit-of-measure gotchas that break naive implementations.
Prerequisites
This assembler assumes a specific upstream state and toolchain. Confirm each before applying it:
- Python 3.10+ — the assembler uses
dataclass(frozen=True),Enum, andDecimalthroughout. Never usefloatfor entered value, duty, or fees; ISO 4217 minor-unit rounding is not representable in binary floating point, and CBP reconciles to the cent. - Classified, duty-settled line items. Every input line must already carry a resolved HTSUS code and a duty figure produced by the Duty Formula Calculation Frameworks. The assembler is a projection, not a classifier — it never guesses a code and never recomputes an ad valorem rate from scratch.
- A settled entered value per line. Entered value is the CBP-appraised transaction value (typically FOB, exclusive of international freight and insurance for most US imports). Currency conversion to USD must already be applied upstream; the assembler works only in ISO 4217 USD minor units.
- Fee parameters for the entry date. The merchandise processing fee (MPF) ad valorem rate and its statutory floor and ceiling, plus the harbor maintenance fee (HMF) rate, must be supplied for the entry’s effective date. These change by regulation and are not hardcoded here.
- Structured logging configured (stdlib
loggingwith the%(asctime)s | %(levelname)s | %(name)s | %(message)sformat, orstructlog) so every assembled line and every fee computation is greppable for audit.
Implementation
A single assembler owns the entire projection: grouping classified lines by tariff line, summing entered value and quantity, computing duty and the statutory fees, and emitting an immutable 7501 line record. It performs no classification and no currency math — those are settled upstream.
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Optional
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger("cbp7501_assembler")
CENT = Decimal("0.01")
def money(value: Decimal) -> Decimal:
"""Quantize to USD cents with CBP half-up rounding."""
return value.quantize(CENT, rounding=ROUND_HALF_UP)
class DutyBasis(Enum):
AD_VALOREM = "AD_VALOREM"
SPECIFIC = "SPECIFIC"
FREE = "FREE"
@dataclass(frozen=True)
class ClassifiedLine:
"""A settled input line: classification and duty already computed."""
sku: str
hts_code: str # 10-digit digit-only HTSUS number
entered_value: Decimal # appraised transaction value, USD
duty_amount: Decimal # computed by the duty engine, USD
quantity: Decimal # in the reporting unit of measure
uom: str # UN/ECE Rec 20 code, e.g. "NO", "KG"
basis: DutyBasis
origin_country: str # ISO 3166-1 alpha-2
@dataclass(frozen=True)
class FeeSchedule:
"""Statutory fee parameters valid for the entry date."""
mpf_rate: Decimal # e.g. Decimal("0.003464")
mpf_min: Decimal # per-entry floor
mpf_max: Decimal # per-entry ceiling
hmf_rate: Decimal # e.g. Decimal("0.00125")
hmf_applies: bool # only water-borne entries at HMF ports
@dataclass(frozen=True)
class Form7501Line:
"""One assembled 7501 tariff-line record, ready to serialize."""
line_no: int
hts_code: str
entered_value: Decimal
duty_amount: Decimal
quantity: Decimal
uom: str
origin_country: str
basis: DutyBasis
sku_count: int
assembled_at: datetime = field(default_factory=datetime.utcnow)
class Form7501Assembler:
"""Projects settled ClassifiedLine records into 7501 tariff lines."""
def __init__(self, fees: FeeSchedule):
self._fees = fees
def _group_key(self, line: ClassifiedLine) -> tuple[str, str, str]:
# Tariff-line identity: same HTSUS number, origin, and UOM merge into
# one 7501 line. Mixing origins or units under one line misstates duty.
return (line.hts_code, line.origin_country, line.uom)
def assemble_lines(self, lines: list[ClassifiedLine]) -> list[Form7501Line]:
buckets: dict[tuple[str, str, str], list[ClassifiedLine]] = defaultdict(list)
for line in lines:
if len(line.hts_code) != 10 or not line.hts_code.isdigit():
raise ValueError(
f"SKU {line.sku}: non-filable HTSUS {line.hts_code!r}; "
"only 10-digit statistical suffixes may reach 7501."
)
buckets[self._group_key(line)].append(line)
assembled: list[Form7501Line] = []
# Deterministic order: sort keys so line numbers are reproducible.
for line_no, key in enumerate(sorted(buckets), start=1):
members = buckets[key]
hts, origin, uom = key
entered = money(sum((m.entered_value for m in members), Decimal("0")))
duty = money(sum((m.duty_amount for m in members), Decimal("0")))
qty = sum((m.quantity for m in members), Decimal("0"))
assembled.append(
Form7501Line(
line_no=line_no, hts_code=hts, entered_value=entered,
duty_amount=duty, quantity=qty, uom=uom,
origin_country=origin, basis=members[0].basis,
sku_count=len(members),
)
)
logger.info(
"7501 line %d: HTS %s origin %s qty %s %s | entered %s duty %s (%d SKUs)",
line_no, hts, origin, qty, uom, entered, duty, len(members),
)
return assembled
def compute_fees(self, lines: list[Form7501Line]) -> dict[str, Decimal]:
"""MPF and HMF are entry-level fees on the summed dutiable value."""
total_entered = money(sum((l.entered_value for l in lines), Decimal("0")))
mpf = money(total_entered * self._fees.mpf_rate)
mpf = min(max(mpf, self._fees.mpf_min), self._fees.mpf_max) # floor/ceiling
hmf = (
money(total_entered * self._fees.hmf_rate)
if self._fees.hmf_applies else Decimal("0.00")
)
total_duty = money(sum((l.duty_amount for l in lines), Decimal("0")))
logger.info(
"Entry totals | entered %s duty %s MPF %s HMF %s",
total_entered, total_duty, mpf, hmf,
)
return {
"entered_value": total_entered,
"total_duty": total_duty,
"mpf": mpf,
"hmf": hmf,
"grand_total": money(total_duty + mpf + hmf),
}
The critical contract is that assemble_lines refuses any code that is not a 10-digit HTSUS number: a 6- or 8-digit code is a valid subheading but is not filable on a 7501 line, so it raises rather than transmitting a truncated classification. Fees are computed once at the entry level — MPF is clamped to its statutory floor and ceiling, and HMF is applied only when the entry qualifies — never per line, which would over-collect.
Decimal cents, and MPF and HMF are computed once at the entry level before the grand total goes to ACE.Verification steps
Run these checks against a representative entry before trusting the assembler in production:
- Grouping determinism. Feed the same line items in shuffled order twice. The two
assemble_linesoutputs must be identical, includingline_noassignment — thesorted(buckets)key guarantees reproducible line numbering, which CBP amendment workflows depend on. - Non-filable code rejection. Inject a line with a 6-digit or 8-digit HTSUS value. Assert
assemble_linesraisesValueError; a truncated code must never reach a 7501 line. - Origin and UOM separation. Give two lines the same HTSUS number but different
origin_country(or differentuom). Assert they produce two distinct 7501 lines, not one merged line — merging across origin misstates preferential duty and country-of-origin marking. - Fee floor and ceiling. Run one entry with a tiny entered value and one with a very large value. Assert MPF equals the statutory floor in the first case and the ceiling in the second; an unclamped ad valorem MPF is the most common over- or under-collection bug.
- HMF gating. Assemble an air-freight entry with
hmf_applies=False. Assert HMF is exactlyUSD 0.00— HMF attaches only to water-borne entries through HMF ports, never universally. - Cent-level reconciliation. Independently sum each line’s entered value and duty and compare against
compute_fees. All figures must match to the cent underROUND_HALF_UP; a mismatch means afloatslipped into an upstream value or rate. - Grand-total identity. Assert
grand_total == total_duty + mpf + hmfexactly. This is the figure ACE validates against the payment tender, so any drift is a hard rejection at transmission.
Edge cases & gotchas
- Summing before quantizing, not after. Quantize each line total once, then sum the already-quantized line values for the entry. Summing raw un-quantized values and quantizing only the grand total can drift by a cent against CBP’s line-by-line reconciliation. The assembler quantizes per line in
assemble_linesand re-quantizes the entry total incompute_fees— keep that discipline. - Unit-of-measure mismatch. The HTSUS statistical suffix dictates the reporting UOM (for example
NOfor number,KGfor kilograms,DOZfor dozens). If your source data reports pieces but the schedule expects dozens, the quantity is off by a factor of twelve and ACE flags it. Normalize to the UN/ECE Rec 20 code the schedule mandates before assembly, never after. - Duty-free lines still need a line record. A
DutyBasis.FREEline hasduty_amountofUSD 0.00but still carries entered value that feeds MPF. Do not drop free lines from the entry — their entered value is dutiable-value basis for the merchandise processing fee even when the duty is zero. - Specific and compound duty bases. The assembler sums a settled
duty_amountand never re-derives it, so specific (per-unit) and compound duties pass through correctly only if the duty engine already resolved them. If you see a specific-rate line with duty that scales with value instead of quantity, the defect is upstream in the duty framework, not the assembler. - Entered value is not invoice total. Entered value is the CBP-appraised transaction value, which for most US imports excludes international freight and insurance. Passing a CIF invoice total inflates both duty and MPF. Confirm the value basis before it reaches this stage; if the entry is later corrected, route the delta through the rejection and amendment path in ACE and ABI Rejection Handling.
- Line-count and value validation before send. Assembly produces the numbers, but the decimal precision and field lengths still have to satisfy ACE before transmission. Run the assembled lines through Validating entered value with ACE decimal precision as the gate between this projection and the wire.