Handling multi-currency invoices with Babel
A commercial invoice that reads 1.234,56 EUR on one line and USD 9,870.00 on the next will destroy a duty calculation the instant a naive parser strips the wrong separator, because 1.234,56 misread as a US number becomes one thousand two hundred thirty-four point five six only by luck and 1,23456 by accident. This page shows how to parse and normalize multi-currency monetary amounts off customs commercial invoices using Babel — babel.numbers.parse_decimal with an explicit locale, currency-aware formatting, and a clean handoff to Decimal — so that every entered value reaching CBP is exact, currency-tagged against ISO 4217, and safe for duty math. The rows this consumes come straight from a table extractor such as the one in Extracting line items from commercial invoices with pdfplumber, and the normalized output feeds the entered-value validation in Entry Summary Filing Schemas.
Prerequisites
Before wiring Babel into your invoice pipeline, confirm this upstream state and toolchain:
- Python 3.10+ and
Babelinstalled (pip install Babel). Babel ships the CLDR locale data that knows a German invoice groups with.and decimalizes with,, while a US invoice does the reverse. That locale knowledge is exactly what a hand-rolledstr.replacecannot encode. Decimaleverywhere for money.parse_decimalreturns adecimal.Decimal; keep it that way through duty computation. Never coerce tofloat— ISO 4217 minor-unit rounding and CBP half-up rules are not representable in binary floating point.- A locale hint per document or exporter. You need to know, or reliably infer, the number locale of each amount. That hint comes from the invoice’s country of issue, the exporter profile, or an explicit currency code adjacent to the figure. Parsing without a locale is guessing.
- Extracted raw amount strings. This guide starts from the string cells produced by table extraction — compare the extractor options in pdfplumber vs camelot vs tabula for customs invoice extraction — not from a pre-cleaned feed. The whole point is to normalize messy human-formatted numbers.
- A daily FX rate source, if you convert. Customs valuation converts foreign currency to the entry currency at an authorized rate for the entry date. This page normalizes and tags currency; if you convert, supply the rate as an exact
Decimalfrom your rate table, not a live float.
Implementation
The normalizer below takes a raw amount string plus its number locale and ISO 4217 currency, parses it with Babel into an exact Decimal, and quantizes to the currency’s minor units. It keeps the currency code alongside the value so nothing downstream ever has to guess what 9870.00 is denominated in.
import logging
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional
from babel import Locale
from babel.numbers import parse_decimal, get_currency_precision, NumberFormatError
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("babel_money_normalizer")
# Number locale per currency/exporter. Drives separator interpretation.
DEFAULT_LOCALE_BY_CURRENCY: dict[str, str] = {
"USD": "en_US",
"GBP": "en_GB",
"EUR": "de_DE", # override per exporter: fr_FR, it_IT, etc.
"JPY": "ja_JP",
"CNY": "zh_CN",
"MXN": "es_MX",
}
@dataclass(frozen=True)
class Money:
amount: Decimal # exact, quantized to currency minor units
currency: str # ISO 4217 alpha code, e.g. "EUR"
source_locale: str # locale used to parse, for audit
def _quantize_to_currency(value: Decimal, currency: str) -> Decimal:
"""Round to the ISO 4217 minor-unit count (JPY=0, USD=2, BHD=3)."""
digits = get_currency_precision(currency)
exponent = Decimal(1).scaleb(-digits) # 2 -> 0.01, 0 -> 1
return value.quantize(exponent, rounding=ROUND_HALF_UP)
def normalize_amount(
raw: str,
currency: str,
locale: Optional[str] = None,
) -> Money:
"""Parse a human-formatted amount into an exact, currency-tagged Money."""
currency = currency.strip().upper()
loc = locale or DEFAULT_LOCALE_BY_CURRENCY.get(currency)
if loc is None:
raise ValueError(f"No number locale for currency {currency!r}; supply one.")
# Strip currency symbols/codes and whitespace the exporter may embed,
# leaving digits and locale separators for Babel to interpret.
cleaned = "".join(
ch for ch in raw if ch.isdigit() or ch in "., '-"
).strip()
try:
# strict=True rejects ambiguous grouping instead of silently guessing.
parsed: Decimal = parse_decimal(cleaned, locale=loc, strict=True)
except (NumberFormatError, ValueError) as exc:
logger.warning(
"Unparseable amount raw=%r currency=%s locale=%s: %s",
raw, currency, loc, exc,
)
raise
amount = _quantize_to_currency(parsed, currency)
logger.info("normalized raw=%r -> %s %s (locale=%s)", raw, amount, currency, loc)
return Money(amount=amount, currency=currency, source_locale=loc)
def to_entry_currency(
money: Money,
entry_currency: str,
fx_rate: Decimal,
) -> Money:
"""Convert to the entry currency using an exact, authorized Decimal rate."""
if money.currency == entry_currency:
return money
converted = _quantize_to_currency(money.amount * fx_rate, entry_currency)
logger.info(
"converted %s %s -> %s %s @ %s",
money.amount, money.currency, converted, entry_currency, fx_rate,
)
return Money(converted, entry_currency, money.source_locale)
Two decisions carry the whole design. First, strict=True on parse_decimal makes Babel reject a genuinely ambiguous string rather than silently pick an interpretation — an invoice value is too consequential to guess. Second, get_currency_precision drives quantization per ISO 4217, so JPY correctly rounds to whole yen while USD rounds to cents and BHD to three places, without hardcoded per-currency logic.
Verification steps
Run these checks against a mixed-currency sample before trusting normalization in production:
- Separator inversion. Parse
"1.234,56"withde_DEand"1,234.56"withen_US; both must yield exactlyDecimal("1234.56"). If either returns1.23456or123456, the locale is wrong orstrictis off — this is the failure that silently 1000x’s or 0.001x’s an entered value. - Zero-decimal currencies. Normalize a
JPYamount like"12,000"and assert the result isDecimal("12000")with no fractional part, becauseget_currency_precision("JPY")is 0. A trailing.00on yen signals a hardcoded 2-decimal assumption. - Three-decimal currencies. Normalize a
BHDamount and assert three minor-unit digits survive quantization. Currencies like Bahraini dinar break any pipeline that assumes cents. - Strict rejection. Feed a deliberately ambiguous string (for example
"1,234"where grouping vs. decimal is genuinely unclear for the locale) and assert it raises rather than returning a plausible-but-wrong value. Confirm the raw string lands in quarantine with its locale logged. - Currency-tag preservation. After a full batch, assert every
Moneycarries a valid ISO 4217 code and that no line-item total was ever combined across currencies without conversion. SummingEURandUSDamounts as if fungible is a valuation error. - Conversion exactness. For any
to_entry_currencycall, independently recomputeamount * fx_rateand confirm the quantized result matches to the minor unit underROUND_HALF_UP. A mismatch means afloatrate slipped in.
Edge cases & gotchas
- The EUR locale is not one locale. A German exporter writes
1.234,56 EURbut an Irish one writes1,234.56 EUR; both are euros. Never assumeEURimpliesde_DE. Resolve the number locale from the exporter’s country, not the currency, and overrideDEFAULT_LOCALE_BY_CURRENCYper trading partner. - Non-breaking-space grouping. French and many CLDR locales group with a non-breaking space (
U+00A0) or a narrow no-break space, not a regular space. Table extraction often preserves these bytes, so the cleaning filter must retain them for Babel; stripping all whitespace first turns1 234,56into an unparseable1234,56under the wrong locale. - Currency symbol vs. code collisions. A bare
$may mean USD, CAD, MXN, or AUD depending on the exporter. Do not infer currency from the glyph — require an explicit ISO 4217 code from the invoice header or exporter profile, and route to broker review when it is genuinely absent rather than defaulting to USD. - Trailing minus and parenthesized negatives. Credit lines and rebates appear as
(1,234.56)or1.234,56-. Babel’sstrictparser will reject some of these, so canonicalize the sign before parsing and preserve it as a negativeDecimal— a dropped sign turns a credit into an added duty base. - Percentages and rates are not money. A
5%VAT or discount field must never flow through the money normalizer; it has no currency and different rounding. Keep rate fields on a separate parse path and reserveMoneystrictly for currency-denominated amounts. - Quantize once, at the boundary. Repeated quantization across pipeline stages compounds rounding. Normalize to minor units exactly once here, then carry the exact
Decimaluntouched into duty computation and Entry Summary Filing Schemas, where entered-value precision is validated against ACE rules.
Authoritative references: ISO 4217 (currency codes and minor units); Unicode CLDR (locale number formatting, via Babel); CBP 19 CFR 152.1 and 159.32 (currency of invoice and conversion for entered value).