How to Map EDI 810 Invoices to Internal PO Schemas Permalink to this section
↑ Part of EDI 810 vs 850 Schema Mapping.
When an EDI 810 invoice lands in your pipeline, the mapping layer must resolve it against the originating 850 purchase order, normalize line-level quantities and pricing into a single canonical record, and commit the result to your ERP without ever writing a duplicate financial row. This page is the concrete procedure for that mapping step: deterministic X12 parsing, a strict target schema, segment-to-field transformation, and an idempotent commit. It assumes a Python ETL stack with Pydantic v2 and transactional Postgres writes, and it sits underneath the broader EDI 810 vs 850 Schema Mapping contract that the downstream match engine depends on.
Operational Trigger Signals Permalink to this section
Build this mapping layer the moment any of these measurable conditions appear in your invoice stream — they all indicate that naive string parsing or direct ERP loads are about to leak financial variance:
- Trading-partner volume crosses ~50 invoices/day and manual AP keying can no longer keep pace with the 810 arrival rate.
po_numberlives in two segments (BIG04andREF*PK) and partners populate them inconsistently, producingpo_join_mismatchevents.- Line-item drift exceeds tolerance —
abs(invoiced_qty - po_qty) / po_qty > 0.02or unit-price delta> 0.05on more than 1% of lines, signalling partial shipments or backorder splits. - Duplicate submissions appear — the same
(supplier_id, invoice_number, currency_code)arrives twice via VAN redelivery or AS2 retry. - Mixed-currency invoices require FX normalization before settlement; see Multi-Currency Reconciliation Frameworks.
- Schema drift from a partner introduces unexpected
NTEorAMTloops that break positionalIT1extraction.
Step-by-Step Implementation Permalink to this section
Step 1 — Parse and normalize the X12 envelope Permalink to this section
EDI 810 files use tilde (~) as segment terminators and asterisk (*) as element delimiters. Do not rely on naive str.split; implement a stateful parser that respects hierarchical loops (IT1, PID, AMT) and tolerates carriage returns or malformed line breaks. The parser below normalizes whitespace, isolates segments, groups them by identifier, and logs anomalies for downstream triage.
import logging
from typing import Dict, List
logger = logging.getLogger("edi810.parser")
def parse_edi_810(raw_payload: str) -> Dict[str, List[List[str]]]:
"""
Split a raw EDI 810 payload into a dict of segment_id -> list of element arrays.
Handles ~ terminators, normalizes line breaks, and preserves element order.
"""
normalized = raw_payload.replace("\r\n", "\n").replace("\r", "\n")
segments = [seg.strip() for seg in normalized.split("~") if seg.strip()]
parsed: Dict[str, List[List[str]]] = {}
for seg in segments:
parts = seg.split("*")
seg_id = parts[0]
parsed.setdefault(seg_id, []).append(parts)
# Envelope sanity: ISA must equal IEA, ST must equal SE.
if len(parsed.get("ISA", [])) != len(parsed.get("IEA", [])):
logger.warning("edi_envelope_mismatch isa=%s iea=%s",
len(parsed.get("ISA", [])), len(parsed.get("IEA", [])))
logger.info("edi810_parsed segments=%d distinct_ids=%d",
len(segments), len(parsed))
return parsed
Configuration hardening for this step:
- Set a maximum segment-length guard to reject truncated transmissions before they reach the mapper.
- Enable strict loop-order validation if partners consistently violate X12 4010/5010 loop sequencing.
- Archive the raw payload to an encrypted, immutable store before parsing, per the controls in Data Security Boundaries for Procurement Systems.
Step 2 — Define the strict target schema Permalink to this section
Model the internal PO/invoice record with Pydantic v2 so type coercion errors and procurement-rule violations surface before reconciliation, not after settlement. The same validation discipline is covered in depth under Schema Validation Using Pydantic.
from pydantic import BaseModel, Field, field_validator
from decimal import Decimal, ROUND_HALF_UP
from datetime import date
from typing import Optional, List
class InvoiceLineItem(BaseModel):
line_number: int = Field(ge=1)
supplier_sku: str = Field(min_length=1)
internal_sku: Optional[str] = None
quantity_invoiced: Decimal = Field(ge=0, decimal_places=2)
unit_price: Decimal = Field(ge=0, decimal_places=4)
uom: str = Field(min_length=2, max_length=3, pattern="^[A-Z]{2,3}$")
extended_amount: Optional[Decimal] = None
@field_validator("extended_amount", mode="before")
@classmethod
def calc_extended(cls, v: Optional[Decimal], info) -> Decimal:
if v is not None:
return v.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
qty = info.data.get("quantity_invoiced")
price = info.data.get("unit_price")
if qty and price:
return (qty * price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
raise ValueError("extended_amount requires quantity_invoiced and unit_price")
class InvoiceHeader(BaseModel):
invoice_number: str = Field(min_length=1, max_length=20)
po_number: str = Field(min_length=1, max_length=20)
invoice_date: date
supplier_id: str
currency_code: str = Field(min_length=3, max_length=3)
line_items: List[InvoiceLineItem]
total_amount: Decimal = Field(ge=0, decimal_places=2)
Carry every monetary value as a fixed-point Decimal from the parser through the tolerance check — floating-point representation error can push an otherwise-clean line just outside a tight price window and raise a false breach.
Step 3 — Transform segments into schema fields Permalink to this section
Map X12 segments to model fields with a deterministic traversal. Each rule is positional and auditable:
| X12 Segment | Target Field | Transformation Rule |
|---|---|---|
BIG (02) |
invoice_number |
Strip whitespace. Validate against the duplicate index. |
BIG (03) |
invoice_date |
Parse YYYYMMDD. Reject if future-dated beyond T+3 business days. |
REF*IA / BIG (04) |
po_number |
Resolve with precedence BIG04 → REF*PK. Cross-reference the active PO table; fail if status not in OPEN/PARTIAL. |
IT1 (01) |
line_number |
Cast to int. Validate sequential ordering. |
IT1 (02) |
quantity_invoiced |
Cast to Decimal. ROUND_HALF_UP to 2 places. |
IT1 (04) |
unit_price |
Cast to Decimal. ROUND_HALF_UP to 4 places. |
IT1 (06) |
uom |
Uppercase. Map supplier UOM to internal base unit (e.g., CS → EA via conversion factor). |
TDS (01) |
total_amount |
Compare against SUM(line.extended_amount) within ±0.05 rounding tolerance. |
Use the product identifier (IT106/IT107) as the authoritative line key after alias resolution, and treat IT101 only as a fallback ordinal — sequence-only alignment breaks the instant a supplier reorders, splits, or consolidates invoice lines.
Step 4 — Apply the tolerance gate before commit Permalink to this section
A line is eligible for automatic settlement only when both quantity and relative price fall inside their windows. The exact tolerance calibration is owned by Setting Quantity and Price Tolerance Windows, but the gate this mapper enforces is:
Lines that satisfy both inequalities post to ERP; everything else routes to a tagged exception queue rather than auto-rejecting.
Step 5 — Commit idempotently Permalink to this section
Once validation and the tolerance gate pass, execute an idempotent upsert inside a transaction with explicit rollback. The composite conflict key is what makes a redelivered 810 safe.
import logging
from psycopg2.extras import execute_values
logger = logging.getLogger("edi810.commit")
def commit_reconciliation(invoice: "InvoiceHeader", db_conn) -> None:
"""Upsert header + lines in one transaction; idempotent on (supplier, invoice, currency)."""
with db_conn.cursor() as cur:
try:
cur.execute(
"""INSERT INTO invoices (invoice_number, po_number, invoice_date,
supplier_id, currency_code, total_amount, status)
VALUES (%s, %s, %s, %s, %s, %s, 'RECONCILED')
ON CONFLICT (supplier_id, invoice_number, currency_code)
DO UPDATE SET status = EXCLUDED.status""",
(invoice.invoice_number, invoice.po_number, invoice.invoice_date,
invoice.supplier_id, invoice.currency_code, invoice.total_amount),
)
line_data = [
(invoice.invoice_number, item.line_number, item.supplier_sku,
item.internal_sku, item.quantity_invoiced, item.unit_price,
item.uom, item.extended_amount)
for item in invoice.line_items
]
execute_values(cur,
"""INSERT INTO invoice_lines (invoice_number, line_number, supplier_sku,
internal_sku, qty_invoiced, unit_price, uom, extended_amount)
VALUES %s ON CONFLICT DO NOTHING""",
line_data,
)
db_conn.commit()
logger.info("invoice_committed invoice=%s lines=%d",
invoice.invoice_number, len(line_data))
except Exception as e:
db_conn.rollback()
logger.error("commit_failed invoice=%s error=%s", invoice.invoice_number, e)
raise RuntimeError(f"Transaction failed for invoice {invoice.invoice_number}") from e
Configuration Reference Permalink to this section
| Parameter | Accepted values | Default | Notes |
|---|---|---|---|
segment_terminator |
single char | ~ |
Some partners use \n; read from ISA16 when present. |
element_delimiter |
single char | * |
Taken from the ISA fixed-width header. |
qty_tolerance_abs (τ_qty) |
0–N units | 0 |
Absolute unit slack before exception routing. |
price_tolerance_pct (τ_price) |
0.00–0.15 | 0.05 |
Relative unit-price band; >0.15 should hard-fail. |
total_rounding_tolerance |
Decimal | 0.05 |
TDS vs summed extended_amount. |
invoice_date_future_window |
business days | 3 |
Reject invoice_date beyond T+3. |
manual_review_ceiling |
0.00–1.00 | 0.15 |
Variance above this never auto-settles. |
idempotency_key |
tuple | (supplier_id, invoice_number, currency_code) |
Composite unique index. |
Debugging & Recovery Permalink to this section
Reconciliation pipelines fail predictably. Route each failure to a tagged dead-letter queue (DLQ) entry carrying the raw payload pointer, the failing segment, and the resolution owner.
PO_NOT_FOUND/ stale PO —SELECT status, last_updated FROM purchase_orders WHERE po_number = ?. IfCLOSED, route toAP_HOLD; ifDRAFT, fire the PO-activation webhook. Log{"error": "PO_NOT_FOUND", "po": po_number, "invoice": invoice_number}.QTY_PRICE_VARIANCE— whenabs(invoiced_qty - po_qty) / po_qty > 0.02orabs(price_diff) > 0.05, flag the line for manual review (never auto-reject under themanual_review_ceiling). CompareIT1elements against the 850 baseline to spot partial shipments or backorder splits.DUPLICATE_INVOICE— the composite unique index raises a conflict; returnHTTP 409, log the attempt, and do not process. A pre-commit check against a Redis cache of recently seen(supplier_id, invoice_number, currency_code)keys suppresses VAN/AS2 retries before they hit the database.SCHEMA_DRIFT— unexpectedNTEorAMTloops break positionalIT1parsing. Temporarily relax loop-order enforcement, fall back to keyed regex extraction for critical fields, and watch for segment-count deviations.
Audit-trail fields to persist on every record: raw_payload_uri, parsed_at, mapper_version, tolerance_profile_id, dlq_reason, and the final status transition. Confirm the layer is healthy by tracking the monitoring metrics edi_segment_anomalies_total, po_join_mismatch_total, invoice_duplicate_suppressed_total, and DLQ depth; alert when DLQ depth grows faster than the drain rate.
Final validation checklist
FAQ Permalink to this section
Should I match invoice lines by IT101 sequence or product ID? Permalink to this section
Use the product identifier (IT106/IT107) as the authoritative line key after alias resolution, and treat IT101 as a fallback ordinal only when product IDs are missing. Sequence-only alignment fails the moment a supplier reorders, splits, or consolidates lines — which is routine on partial shipments.
How do I stop a redelivered 810 from paying a supplier twice? Permalink to this section
Persist an idempotency key of (supplier_id, invoice_number, currency_code) (extend with line_sequence for line-level safety) and enforce it as a composite unique index before the ERP post. A VAN redelivery or AS2 retry then maps to the same key and is suppressed at the commit boundary, keeping an at-least-once transport effectively exactly-once.
What belongs in this mapping layer versus the match engine? Permalink to this section
This layer only parses, normalizes both documents into the canonical schema, and validates structure plus the tolerance gate; it does not adjudicate disputes. Tier selection and exception resolution belong to the downstream match engine, which keeps the mapper stateless and fully replayable from the archived raw payload.