Validating entered value with ACE decimal precision

ACE rejects an entry summary the instant a monetary field carries more decimal places than the schema allows, exceeds its field length, or was rounded with the wrong rule — and a rejection at transmission time is far more expensive than a validation failure inside your own pipeline. Entered value, duty, and the merchandise and harbor maintenance fees each have a fixed precision and a maximum magnitude in the ACE entry-summary record, and every one of them must be quantized with banker-proof half-up rounding to the cent before the payload leaves your process. The correct behavior is deterministic: take the assembled monetary fields, quantize each to its declared scale with ROUND_HALF_UP, assert it fits the field’s integer and fractional width, and reject the record locally — with a precise reason — before a single byte reaches ABI. This page gives you a single runnable validator that enforces that contract, plus the verification checklist and the rounding, scale, and field-length gotchas that break naive implementations.

Prerequisites

This validator assumes a specific upstream state and toolchain. Confirm each before applying it:

  • Python 3.10+ — the validator uses dataclass(frozen=True), Enum, and Decimal throughout. Never use float for any monetary field; float cannot represent 0.005 exactly, so a float value at a rounding boundary rounds unpredictably and ACE flags the mismatch.
  • Assembled 7501 line records. The validator runs on the output of Assembling CBP 7501 line items from classified entries — line records whose entered value and duty are already summed. It validates precision and magnitude; it does not compute or group.
  • A field precision contract for the target ACE record. Each monetary field’s total digit width and fractional scale must be declared. The values below are illustrative — always confirm the widths against the current CBP ACE ABI CATAIR entry-summary record layout for your filing type before production.
  • Decimal context wide enough for intermediate math. Set the process decimal context precision high enough that no intermediate multiplication silently loses digits before you quantize; the default 28 is ample for entry-summary magnitudes.
  • Structured logging configured (stdlib logging with the %(asctime)s | %(levelname)s | %(name)s | %(message)s format, or structlog) so every rejection carries a machine-readable field and reason for the audit trail.

Implementation

A single validator owns the entire pre-transmission money gate: quantize each field to its declared scale, verify it is non-negative and finite, and assert its integer and fractional widths fit the ACE field. Any violation returns a structured rejection rather than a coerced value — the record never silently mutates to fit.

import logging
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
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("ace_precision_validator")


class RejectReason(Enum):
    NOT_FINITE = "NOT_FINITE"
    NEGATIVE = "NEGATIVE"
    SCALE_OVERFLOW = "SCALE_OVERFLOW"      # more fractional digits than allowed
    INTEGER_OVERFLOW = "INTEGER_OVERFLOW"  # magnitude exceeds field width


@dataclass(frozen=True)
class FieldSpec:
    """Precision contract for one ACE monetary field."""
    name: str
    int_digits: int       # max digits left of the decimal point
    frac_digits: int      # fixed digits right of the decimal point (scale)

    @property
    def quantum(self) -> Decimal:
        return Decimal(1).scaleb(-self.frac_digits)  # e.g. Decimal("0.01")

    @property
    def max_magnitude(self) -> Decimal:
        # Largest value the field can hold, e.g. 9,999,999,999.99
        return Decimal(10) ** self.int_digits - self.quantum


@dataclass(frozen=True)
class FieldResult:
    name: str
    ok: bool
    value: Optional[Decimal]
    reason: Optional[RejectReason]


# Illustrative ACE entry-summary money fields — confirm against CATAIR.
ACE_FIELDS = {
    "entered_value": FieldSpec("entered_value", int_digits=12, frac_digits=2),
    "duty_amount":   FieldSpec("duty_amount",   int_digits=12, frac_digits=2),
    "mpf":           FieldSpec("mpf",           int_digits=10, frac_digits=2),
    "hmf":           FieldSpec("hmf",           int_digits=10, frac_digits=2),
}


class AcePrecisionValidator:
    """Quantizes and range-checks money fields before ABI transmission."""

    def validate_field(self, spec: FieldSpec, raw: Decimal) -> FieldResult:
        # 1. Reject NaN / Infinity outright — never transmit a non-finite money value.
        try:
            if not raw.is_finite():
                return self._reject(spec, RejectReason.NOT_FINITE, raw)
        except (InvalidOperation, AttributeError):
            return self._reject(spec, RejectReason.NOT_FINITE, None)

        # 2. Entered value, duty, and fees are never negative on a 7501 line.
        if raw < 0:
            return self._reject(spec, RejectReason.NEGATIVE, raw)

        # 3. Quantize to the field's fixed scale with CBP half-up rounding.
        quantized = raw.quantize(spec.quantum, rounding=ROUND_HALF_UP)

        # 4. Guard against silent scale overflow: if the raw value carried more
        #    fractional precision than the field allows, quantizing changed it.
        #    That is data loss, not a formatting fix — reject and surface it.
        if raw != quantized and _frac_len(raw) > spec.frac_digits:
            return self._reject(spec, RejectReason.SCALE_OVERFLOW, raw)

        # 5. Magnitude must fit the declared integer width.
        if quantized > spec.max_magnitude:
            return self._reject(spec, RejectReason.INTEGER_OVERFLOW, quantized)

        logger.info("field %s OK: %s", spec.name, quantized)
        return FieldResult(spec.name, True, quantized, None)

    def validate_record(self, record: dict[str, Decimal]) -> list[FieldResult]:
        results: list[FieldResult] = []
        for name, spec in ACE_FIELDS.items():
            raw = record.get(name, Decimal("0"))
            results.append(self.validate_field(spec, raw))
        rejects = [r for r in results if not r.ok]
        if rejects:
            logger.error(
                "Record rejected before send: %s",
                ", ".join(f"{r.name}={r.reason.value}" for r in rejects),
            )
        return results

    def _reject(self, spec: FieldSpec, reason: RejectReason,
                value: Optional[Decimal]) -> FieldResult:
        logger.warning("field %s rejected: %s (raw=%r)", spec.name, reason.value, value)
        return FieldResult(spec.name, False, value, reason)


def _frac_len(value: Decimal) -> int:
    """Count significant fractional digits in a Decimal."""
    exponent = value.normalize().as_tuple().exponent
    return -exponent if isinstance(exponent, int) and exponent < 0 else 0


def record_is_transmittable(results: list[FieldResult]) -> bool:
    """Hard gate: every field must pass or the record does not go to ABI."""
    return all(r.ok for r in results)

The critical distinction is in step 4: quantizing to fit the field is not the same as accepting a value that was always at the right scale. A duty figure of USD 1,528.0500 did not lose meaning when rounded to USD 1,528.05, but a value that arrives as USD 1,528.055 carried a third decimal place that the upstream duty engine should never have produced — that is a SCALE_OVERFLOW defect, and the validator surfaces it instead of silently rounding the evidence away.

The per-field decision path for ACE decimal-precision validation A raw monetary Decimal field enters the validator at the top. The first gate asks whether the value is finite; a NaN or infinity is rejected as NOT_FINITE. The second gate asks whether the value is non-negative; a negative entered value, duty, or fee is rejected as NEGATIVE. The value is then quantized to the field's fixed scale with ROUND_HALF_UP rounding. A third gate compares the raw value against the quantized value: if they differ because the raw value carried more fractional digits than the field allows, the record is rejected as SCALE_OVERFLOW rather than silently rounded. The fourth gate checks magnitude against the field's integer width; a value too large is rejected as INTEGER_OVERFLOW. Only a value that is finite, non-negative, at the correct scale, and within range passes and is marked transmittable to ABI. raw Decimal field entered value · duty · MPF · HMF is finite? no NOT_FINITE value >= 0? no NEGATIVE quantize to scale ROUND_HALF_UP raw scale within field digits? no SCALE_OVERFLOW within scale fits width -> transmittable else INTEGER_OVERFLOW
Each money field must pass four gates — finite, non-negative, at the field's declared scale, and within its integer width — before the record is marked transmittable; quantizing that changes the value is treated as SCALE_OVERFLOW, not a silent fix.

Verification steps

Run these checks against a representative record before trusting the validator in production:

  1. Rounding-boundary correctness. Validate Decimal("1528.055") against a 2-scale field. Assert it is rejected as SCALE_OVERFLOW — and separately, that Decimal("1528.045") quantizes to USD 1,528.05 under ROUND_HALF_UP, proving the half-up rule, not banker’s rounding, is in force.
  2. Non-finite rejection. Feed Decimal("NaN") and Decimal("Infinity"). Both must return NOT_FINITE; a non-finite money value must never reach ABI, where it serializes to garbage.
  3. Negative guard. Validate Decimal("-0.01") for entered value. Assert NEGATIVE; a sign error upstream must fail here, not at CBP. Refund and correction deltas are handled through amendments, not a negative 7501 line.
  4. Integer-width ceiling. Validate a value one quantum above max_magnitude for the field. Assert INTEGER_OVERFLOW; an entered value that overflows the field width is a data defect, most often a currency-conversion or unit error.
  5. Clean-value passthrough. Validate Decimal("24800.00") for a 12-integer, 2-scale field. Assert ok is True and the returned value is bit-identical Decimal("24800.00") — a clean value must survive untouched, with no scale drift.
  6. Whole-record gate. Build a record where three fields pass and one fails. Assert record_is_transmittable returns False and the log names the failing field and reason. A partial pass must block the entire record.
  7. Zero-scale integrity. Confirm Decimal("0") and a missing field both validate to USD 0.00 without triggering a false SCALE_OVERFLOW. Duty-free and non-applicable fees are legitimately zero, not errors.

Edge cases & gotchas

  • Half-up versus banker’s rounding. Python’s Decimal defaults to ROUND_HALF_EVEN (banker’s rounding). CBP arithmetic expects ROUND_HALF_UP. If you omit the explicit rounding= argument anywhere in the money path, a value ending in exactly five in the discarded place rounds the wrong way half the time, and the mismatch surfaces only on a fraction of entries — the hardest kind of bug to reproduce. Always pass ROUND_HALF_UP explicitly, in the assembler and here.
  • Float contamination is invisible until the boundary. A value that spent any part of its life as a float may look correct — 24800.00 — yet be 24800.000000000003 internally, which quantizes fine but can trip scale checks elsewhere. Assert isinstance(value, Decimal) at the pipeline boundary and construct every Decimal from a string, never from a float literal.
  • Quantizing is not validating. The most dangerous shortcut is to quantize every field and transmit, trusting rounding to make values fit. That silently discards precision the upstream engine should never have produced and destroys the evidence that something is wrong. Distinguish “reformatting a correctly scaled value” from “truncating an over-precise one,” as step 4 does.
  • Field widths differ by record and filing type. The illustrative widths here are not universal. Entered value, duty, and fee fields can carry different integer and fractional widths across ACE record types and CATAIR versions. Load the widths from the current CATAIR layout for your filing type rather than hardcoding, and re-verify after every CBP schema release.
  • Scale is fixed, not maximum. ACE money fields are fixed-scale: USD 5.00 must serialize with exactly two fractional digits, not 5 or 5.0. When you format the quantized Decimal for the wire, preserve trailing zeros — str(Decimal("5.00")) already yields "5.00", but a naive float-based formatter would drop them and fail length validation.
  • Reject locally, then route the failure. A field that fails here should never be silently dropped; it is a data-quality signal. Feed the structured rejection into the same operational path that handles a returned entry so the correction is tracked, using ACE and ABI Rejection Handling, and only re-run validation once the assembler has produced corrected 7501 line items.

Up: Entry Summary Filing Schemas