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 Babel installed (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-rolled str.replace cannot encode.
  • Decimal everywhere for money. parse_decimal returns a decimal.Decimal; keep it that way through duty computation. Never coerce to float — 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 Decimal from 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.

Parse to normalize to Decimal pipeline for multi-currency amounts A left-to-right four-stage pipeline for one invoice amount. Stage one, raw cell: the extracted string such as 1.234,56 EUR with an exporter locale hint. Stage two, clean and locate: strip currency symbols and whitespace and select the number locale from the currency, for example EUR maps to de underscore DE. Stage three, Babel parse decimal with strict true: the locale interprets the dot as a grouping separator and the comma as the decimal separator, producing an exact Decimal 1234.56. Stage four, quantize to ISO 4217 minor units using get currency precision, yielding a currency-tagged Money value of 1234.56 EUR ready for duty math. A branch below shows that an ambiguous or unparseable string raises a NumberFormatError and is quarantined rather than guessed. A final note shows optional FX conversion to the entry currency using an exact Decimal rate. raw cell 1.234,56 EUR + locale hint clean + locate strip symbols EUR → de_DE parse_decimal strict=True Decimal 1234.56 , = decimal sep quantize minor units Money 1234.56 EUR ambiguous → raise NumberFormatError, quarantine optional FX → entry currency
The locale, not the raw string, decides which mark is the decimal separator; strict parsing quarantines ambiguity instead of guessing, and quantization tags every value against ISO 4217.

Verification steps

Run these checks against a mixed-currency sample before trusting normalization in production:

  1. Separator inversion. Parse "1.234,56" with de_DE and "1,234.56" with en_US; both must yield exactly Decimal("1234.56"). If either returns 1.23456 or 123456, the locale is wrong or strict is off — this is the failure that silently 1000x’s or 0.001x’s an entered value.
  2. Zero-decimal currencies. Normalize a JPY amount like "12,000" and assert the result is Decimal("12000") with no fractional part, because get_currency_precision("JPY") is 0. A trailing .00 on yen signals a hardcoded 2-decimal assumption.
  3. Three-decimal currencies. Normalize a BHD amount and assert three minor-unit digits survive quantization. Currencies like Bahraini dinar break any pipeline that assumes cents.
  4. 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.
  5. Currency-tag preservation. After a full batch, assert every Money carries a valid ISO 4217 code and that no line-item total was ever combined across currencies without conversion. Summing EUR and USD amounts as if fungible is a valuation error.
  6. Conversion exactness. For any to_entry_currency call, independently recompute amount * fx_rate and confirm the quantized result matches to the minor unit under ROUND_HALF_UP. A mismatch means a float rate slipped in.

Edge cases & gotchas

  • The EUR locale is not one locale. A German exporter writes 1.234,56 EUR but an Irish one writes 1,234.56 EUR; both are euros. Never assume EUR implies de_DE. Resolve the number locale from the exporter’s country, not the currency, and override DEFAULT_LOCALE_BY_CURRENCY per 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 turns 1 234,56 into an unparseable 1234,56 under 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) or 1.234,56-. Babel’s strict parser will reject some of these, so canonicalize the sign before parsing and preserve it as a negative Decimal — 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 reserve Money strictly 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 Decimal untouched 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).

Up: Commercial Invoice PDF Extraction