Skip to content

Parsing EDI 810 Invoices with Python

When a vendor transmits an X12 810 (Invoice) and the off-invoice allowance is buried in a SAC segment that your parser silently skips, the resulting accrual is understated and the promotional claim fails reconciliation weeks later as an unexplained deduction. This page documents the exact procedure for turning a raw 810 transaction set into a typed, audit-ready record — extracting BIG/REF header identifiers, IT1 detail lines, and SAC allowance/charge amounts so that every promotional dollar maps deterministically to a contract. It is the implementation-level companion to the CSV & EDI parsing workflows cluster, which governs how heterogeneous vendor submissions enter the broader data ingestion normalization pipelines.

X12 810 envelope mapped to the canonical InvoiceRecord The interchange (ISA to IEA) wraps a functional group (GS to GE), which wraps an 810 transaction set (ST*810 to SE). Inside the transaction set, the BIG header, an N1/REF loop, a repeating IT1 detail line with a trailing SAC allowance, and a TDS/CTT summary are each extracted into fields of the canonical InvoiceRecord: BIG02 becomes invoice_number, REF*ZZ becomes promotion_code, IT102 times IT104 becomes extended_amount, and SAC05 divided by 100 becomes allowance_amount. ISA … IEA · interchange envelope GS … GE · functional group ST*810 … SE · transaction set BIG · invoice header …*INV-7741*…*PO-3320 N1*VN · REF loop REF*ZZ*PROMO-Q3 · REF*CR IT1 · detail line qty 240 · CT · price 18.50 SAC*A · allowance …*…*…*500 (cents) repeats × N lines per invoice TDS · CTT · summary total · line count (reconcile) InvoiceRecord (canonical) header. invoice_number header. promotion_code lines[]. extended_amount lines[]. allowance_amount all monetary fields · Decimal BIG02 REF*ZZ IT102 × IT104 SAC05 ÷ 100

Prerequisites

Before parsing a single segment, confirm the following are in place:

  • A trading-partner data contract declaring the X12 version (commonly 004010 or 005010), the segment terminator, and the element/sub-element separators. Never hardcode ~ and * — read them from ISA16 and ISA11/ISA105 so a partner who ships | separators does not silently corrupt your split.
  • A canonical reconciliation schema with stable field names (invoice_number, po_number, vendor_id, promotion_code, allowance_amount). Mapping the vendor-specific qualifiers onto these names is the job of field mapping strategies.
  • Python packages: pydantic>=2.6 for schema validation and aiofiles>=23 for async I/O. All monetary fields use decimal.Decimal from the standard library — never float.
  • Access role: read access to the inbound SFTP/object-store drop and write access to the dead-letter queue (DLQ) and quarantine tables (typically the reconciliation_etl service role).
  • A sample corpus of real 810 files per trading partner, including at least one with a multi-SAC line and one with an empty optional BIG03 PO date, so the parser is tested against the variants that actually break it.

Step-by-step implementation

Step 1 — Split the interchange into transaction sets on the real terminator

X12 segments are delimited by the terminator declared in ISA16, not by newlines. Trading partners frequently insert \r\n purely for human readability; those bytes are cosmetic and must be stripped before splitting. Read the file asynchronously so a slow transmission never blocks the worker pool — the same async discipline used when implementing async batch queues for sales data.

python
import aiofiles
from pathlib import Path
from typing import AsyncIterator, List

async def stream_edi_segments(file_path: Path) -> AsyncIterator[List[str]]:
    """Yield one ST/SE transaction set's worth of segments at a time."""
    async with aiofiles.open(file_path, mode="r", encoding="utf-8") as f:
        raw = await f.read()

    terminator = raw[105] if raw.startswith("ISA") and len(raw) > 105 else "~"
    raw = raw.replace("\r", "").replace("\n", "")
    segments = [s.strip() for s in raw.split(terminator) if s.strip()]

    tx: List[str] = []
    in_tx = False
    for seg in segments:
        if seg.startswith("ST*"):
            in_tx, tx = True, [seg]
        elif seg.startswith("SE*"):
            tx.append(seg)
            yield tx
            in_tx, tx = False, []
        elif in_tx:
            tx.append(seg)

Validation check: every yielded tx list starts with ST* and ends with SE*, and the SE01 segment count equals len(tx). A mismatch means a dropped or duplicated segment — route the whole transaction set to the DLQ with the raw payload retained.

Step 2 — Extract the header from BIG, REF, and N1

The BIG segment carries the invoice date (BIG01), invoice number (BIG02), PO date (BIG03, optional), and PO number (BIG04). The N1*VN loop identifies the vendor, and REF qualifiers carry the trade-specific identifiers reconciliation depends on — REF*ZZ (Mutually Defined) almost always encodes the promotion or campaign code, and REF*CR carries the contract number that links the invoice back to payout structure modeling.

python
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime

class InvoiceHeader(BaseModel):
    invoice_number: str
    invoice_date: datetime
    po_number: str
    vendor_id: str
    promotion_code: Optional[str] = None
    contract_id: Optional[str] = None

def parse_header(segments: List[str]) -> InvoiceHeader:
    big = next((s for s in segments if s.startswith("BIG*")), None)
    if not big:
        raise ValueError("Missing mandatory BIG segment")
    p = big.split("*")  # p[1]=date, p[2]=invoice#, p[3]=PO date, p[4]=PO#

    def ref(qual: str) -> Optional[str]:
        seg = next((s for s in segments if s.startswith(f"REF*{qual}*")), None)
        return seg.split("*")[2] if seg else None

    # N1*VN: element 2 = org name; element 4 = vendor ID when element 3 == "92"
    vn = next((s for s in segments if s.startswith("N1*VN*")), "").split("*")
    vendor_id = vn[4] if len(vn) > 4 else ""

    return InvoiceHeader(
        invoice_number=p[2],
        invoice_date=datetime.strptime(p[1], "%Y%m%d"),
        po_number=p[4] if len(p) > 4 else "",
        vendor_id=vendor_id,
        promotion_code=ref("ZZ"),
        contract_id=ref("CR"),
    )

Validation check: assert invoice_number and po_number are non-empty and invoice_date parsed without raising. A missing PO number is a semantic error, not a syntax error — flag it for vendor master-data correction rather than discarding the invoice.

Step 3 — Parse detail lines and promotional allowances from IT1 and SAC

Line-level granularity is what lets analysts reconcile unit costs against contracted tiers. IT1 carries IT101 (line number), IT102 (quantity), IT103 (unit of measure), IT104 (unit price), and IT105 (a basis-of-unit-price qualifiernot an amount). The extended line amount is not a standard IT1 element; compute it as quantity × unit_price. Each SAC that follows a line carries SAC01 (A allowance / C charge), SAC02 (the allowance/charge code), and SAC05 (the amount). Keep every figure in Decimal.

python
from decimal import Decimal

class LineItem(BaseModel):
    line_number: str
    quantity: Decimal
    uom: str
    unit_price: Decimal
    extended_amount: Decimal
    allowance_amount: Decimal = Decimal("0")
    allowance_code: Optional[str] = None

def parse_lines(segments: List[str]) -> List[LineItem]:
    lines: List[LineItem] = []
    for seg in segments:
        if seg.startswith("IT1*"):
            p = seg.split("*")
            qty, price = Decimal(p[2]), Decimal(p[4])
            lines.append(LineItem(
                line_number=p[1],
                quantity=qty,
                uom=p[3],
                unit_price=price,
                extended_amount=(qty * price).quantize(Decimal("0.01")),
            ))
        elif seg.startswith("SAC*") and lines:
            sp = seg.split("*")
            # SAC05 is denominated in the currency's minor unit (cents for USD)
            if len(sp) > 5 and sp[1] == "A":
                lines[-1].allowance_amount += Decimal(sp[5]) / 100
                lines[-1].allowance_code = sp[2]
    return lines

Validation check: for each line, assert extended_amount == (quantity * unit_price).quantize(Decimal("0.01")) and that allowance_amount <= extended_amount. An allowance exceeding the line value signals a units error (dollars vs cents) — quarantine the line before it deflates an accrual.

Step 4 — Assemble and validate the canonical record

Compose the header and lines into one immutable record and reconcile it against the 810 summary segments (TDS total, CTT line count) before emitting. This is the contract boundary downstream consumers trust.

python
class InvoiceRecord(BaseModel):
    header: InvoiceHeader
    lines: List[LineItem]

    @property
    def net_payable(self) -> Decimal:
        gross = sum((l.extended_amount for l in self.lines), Decimal("0"))
        allow = sum((l.allowance_amount for l in self.lines), Decimal("0"))
        return (gross - allow).quantize(Decimal("0.01"))

def build_record(segments: List[str]) -> InvoiceRecord:
    return InvoiceRecord(header=parse_header(segments), lines=parse_lines(segments))

Validation check: assert len(record.lines) equals the CTT01 count and, where the partner sends TDS, that record.net_payable * 100 == Decimal(tds01) within a one-cent tolerance. A divergence is a business-logic error routed to the reconciliation exception queue, not a parse failure.

Step 5 — Route emitted records to settlement and sync

Decouple parsing from downstream sync so failed batches replay without reprocessing entire vendor files. Match each record on the composite key vendor_id + po_number + promotion_code against pre-approved trade spend, then hand off to POS & ERP sync patterns for idempotent upsert into SAP S/4HANA or NetSuite. Promotion codes absent from the active catalog are escalated through fallback routing logic rather than auto-approved.

Validation check: the upsert is keyed on a deterministic record hash so a replayed batch is a no-op; assert that re-running the same file produces zero net ledger change.

Common failure modes and fixes

  1. Splitting on newlines instead of the terminator. Partners who pretty-print one segment per line will parse fine until one ships a single-line file and every segment collapses into one. Always split on the ISA16 terminator after stripping \r\n, as in Step 1.
  2. Reading IT105 as the extended amount. IT105 is a basis-of-unit-price qualifier string (e.g. CT), not dollars. Casting it to Decimal raises InvalidOperation at best and books a garbage amount at worst. Compute extended_amount = quantity × unit_price.
  3. Treating SAC05 as whole dollars. SAC05 is denominated in the currency’s minor unit. A 500 cent allowance booked as $500 instead of $5.00 overstates the deduction a hundredfold. Divide by 100 before any comparison against contracted rates.
  4. Float money arithmetic. Summing extended_amount as float accumulates rounding drift across thousands of lines and pushes accruals into the wrong tier. Parse as Decimal at the boundary and quantize to two places.
  5. Assuming one SAC per line. A single line can carry stacked allowances (off-invoice plus freight). Overwriting rather than accumulating loses every allowance but the last. Use += on allowance_amount and capture each code, as in Step 3.

Operational checklist

Frequently asked questions

Why split on ~ (or ISA16) instead of newlines? Newlines are not part of X12 syntax — the terminator is declared in ISA16 and is the only authoritative segment boundary. Many partners add line breaks for readability and some send none at all, so a newline split is non-deterministic across the same trading relationship.

Where does the promotion code actually live in an 810? There is no dedicated standard element. In practice it rides on REF*ZZ (Mutually Defined) at the header, occasionally on a line-level REF, and sometimes inside SAC15 (description). Confirm the location per trading partner in the data contract rather than assuming.

Should I read the extended line amount from a segment or compute it? Compute it as quantity × unit_price and quantize to two decimals. IT1 has no standard extended-amount element; values that do appear in TXI/CTP are tax or pricing context and must be reconciled against the computed figure, not trusted blindly.

What happens when the SAC allowance exceeds the line value? That is almost always a units error — dollars submitted where cents are expected. Quarantine the line with an explicit reason code so it reaches manual adjudication instead of silently producing a negative payable.