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.
Prerequisites
Before parsing a single segment, confirm the following are in place:
- A trading-partner data contract declaring the X12 version (commonly
004010or005010), 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.6for schema validation andaiofiles>=23for async I/O. All monetary fields usedecimal.Decimalfrom the standard library — neverfloat. - 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_etlservice role). - A sample corpus of real 810 files per trading partner, including at least one with a multi-
SACline and one with an empty optionalBIG03PO 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.
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.
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 qualifier — not 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.
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.
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
- 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
ISA16terminator after stripping\r\n, as in Step 1. - Reading IT105 as the extended amount. IT105 is a basis-of-unit-price qualifier string (e.g.
CT), not dollars. Casting it toDecimalraisesInvalidOperationat best and books a garbage amount at worst. Computeextended_amount = quantity × unit_price. - Treating SAC05 as whole dollars. SAC05 is denominated in the currency’s minor unit. A
500cent allowance booked as$500instead of$5.00overstates the deduction a hundredfold. Divide by 100 before any comparison against contracted rates. - Float money arithmetic. Summing
extended_amountasfloataccumulates rounding drift across thousands of lines and pushes accruals into the wrong tier. Parse asDecimalat the boundary andquantizeto two places. - 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
+=onallowance_amountand 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.
Related
- Parent cluster: CSV & EDI Parsing Workflows
- Normalizing SKU Hierarchies Across Retailers — resolving vendor part numbers from
IT1into canonical SKUs - Implementing Async Batch Queues for Sales Data — scaling the parser across high-volume file drops
- Automating POS Data Extraction for CPG — closing the accrual loop against sell-through