
Automate Accounts Payable in Mexico: Extract Supplier Invoices with Claude, Reconcile Against Received CFDI, and Monitor 69-B (Python, 2026)
In Mexican accounts payable, some documents are valid CFDI XML you validate against the SAT; many arrive as non-CFDI PDFs, tickets or photos. Use Claude Structured Outputs to extract typed fields, then reconcile in code — not the LLM — matching UUID, RFC emisor, RFC receptor and total string-exact against the received CFDI XML, and run a scheduled 69-B monitor for retroactive risk.
Can you automate accounts payable in Mexico with an LLM?
Yes, if you split the job in two. Use Claude Structured Outputs to extract typed fields from the messy non-CFDI PDFs and photos, then reconcile in code — never the LLM — matching UUID, RFC and total string-exact against the received CFDI XML. Finish with a scheduled 69-B monitor so a supplier you already booked can’t quietly flip to Definitivo behind your back.
The rule that keeps this safe: the LLM extracts, your code decides. Everything below is Python you can ship, and every SAT fact matches what you’d verify against sat.gob.mx yourself.
The AP reality in Mexico: clean CFDI XML on one side, messy PDFs on the other
Walk into any Mexican finance inbox and you get two piles. The clean pile is proper CFDI XML — the invoice your supplier timbró through a PAC, with a UUID, a sello, and a canonical total. You validate those exactly like the outbound flow in reverse; the validate-received-CFDI sibling post covers the XML, sello and vigencia checks in full crypto detail.
The messy pile is everything else: a foreign supplier’s PDF invoice, a scanned OXXO ticket, a photo of a receipt someone took in a taxi. None of it is CFDI. None of it validates against the SAT. But it still has to be booked, matched to a payment, and — where a CFDI exists — reconciled against it.
So the pipeline has two distinct responsibilities: extract structured data from the messy documents, and reconcile that data against the authoritative CFDI XML. Conflating those two is where teams get burned, because the extraction step is probabilistic and the reconciliation step must be exact.
Extract with Claude Structured Outputs: schema-guaranteed JSON, no brittle regex
Point Claude at the PDF or image and force a JSON schema so the response always matches your typed shape — emisor RFC, receptor RFC, UUID if present, subtotal, IVA, total, date, and concept lines. No prompt-and-pray, no regex-repairing fenced JSON. The Claude Structured Outputs post covers the output_config.format mechanics; here we just consume it.
One guardrail first: if the document carries CURP, full CLABE or other sensitive data you don’t need, redact the Mexican PII before it hits the LLM. For AP extraction you want RFCs and totals, not personal data.
import base64
from anthropic import Anthropic
client = Anthropic()
AP_SCHEMA = {
"type": "object",
"additionalProperties": False,
"required": ["rfc_emisor", "rfc_receptor", "uuid", "subtotal", "iva", "total", "fecha"],
"properties": {
"rfc_emisor": {"type": "string"},
"rfc_receptor": {"type": "string"},
"uuid": {"type": ["string", "null"]}, # many non-CFDI docs have none
"subtotal": {"type": "string"}, # strings, not floats — see below
"iva": {"type": "string"},
"total": {"type": "string"},
"fecha": {"type": "string"},
"conceptos": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"required": ["descripcion", "importe"],
"properties": {
"descripcion": {"type": "string"},
"importe": {"type": "string"},
},
},
},
},
}
def extract(pdf_bytes: bytes) -> dict:
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
output_config={"format": {"type": "json_schema", "schema": AP_SCHEMA}},
messages=[{
"role": "user",
"content": [
{"type": "document",
"source": {"type": "base64", "media_type": "application/pdf",
"data": base64.standard_b64encode(pdf_bytes).decode()}},
{"type": "text", "text": "Extract the invoice fields. Copy totals verbatim, do not recompute."},
],
}],
)
import json
return json.loads(resp.content[0].text)
Notice the money fields are strings, not floats. That’s deliberate: when you later compare your extracted total against the CFDI XML Total, that comparison must be string-exact with two decimals, and float("1234.00") will happily become 1234.0 and blow it. Keep money as text end to end.
Reconcile in code, not the LLM: match UUID, RFC emisor, RFC receptor and total
Here’s the line teams cross and regret: asking the model “does this PDF match this CFDI?” Don’t. Matching is a deterministic equality check on four hard keys, and it belongs in Python where it’s auditable and never hallucinates.
from decimal import Decimal, ROUND_HALF_UP
def norm_total(s: str) -> str:
# canonical: two decimals, string-exact — for XML-vs-extraction reconciliation
return str(Decimal(s).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def reconcile(extracted: dict, cfdi_xml: dict) -> list[str]:
"""cfdi_xml = fields parsed from the authoritative received CFDI."""
problems = []
if extracted["uuid"] != cfdi_xml["uuid"]:
problems.append("uuid_mismatch")
if extracted["rfc_emisor"].upper() != cfdi_xml["rfc_emisor"].upper():
problems.append("rfc_emisor_mismatch")
if extracted["rfc_receptor"].upper() != cfdi_xml["rfc_receptor"].upper():
problems.append("rfc_receptor_mismatch")
if norm_total(extracted["total"]) != norm_total(cfdi_xml["total"]):
problems.append("total_mismatch")
return problems
If reconcile returns an empty list, the extracted document and the CFDI agree on identity and amount. Anything in that list is a hold, not a booking. The model gave you candidate fields; your code decided whether they’re trustworthy. That separation is the whole architecture in one function.
Flag what fails: apócrifos, duplicate UUIDs, and totals that drift
Three failure classes matter, and each maps to a concrete check you can run before anything touches the ledger.
- Apócrifos — a PDF whose numbers match no real CFDI. If a supplier hands you a PDF claiming a UUID, but querying the SAT for that UUID+RFC+total returns no valid comprobante, you’re looking at a fabricated or altered document. That’s a validation call, covered next.
- Duplicate UUIDs — the same UUID booked twice, which is how you accidentally pay an invoice two times. A unique constraint on
uuidin your AP table catches it at insert. - Total drift — the PDF total and the CFDI XML total disagree by even a centavo.
total_mismatchfromreconcilealready flags it; the point is never to “round it off” in code.
def gate(extracted, cfdi_xml, already_booked_uuids: set) -> str:
if extracted["uuid"] in already_booked_uuids:
return "HOLD: duplicate_uuid"
problems = reconcile(extracted, cfdi_xml)
if problems:
return "HOLD: " + ",".join(problems)
return "OK"
The vigencia + 69-B gate: reuse ConsultaCFDIService and the Listado Completo
Reconciliation proves the PDF matches a CFDI. It does not prove that CFDI is still valid or that the supplier is clean. That’s the gate — and you don’t rebuild it here, you reuse the ConsultaCFDIService client and the 69-B list from the validation sibling post, which has the sello and crypto detail.
The summary you need to book correctly: query the SAT’s ConsultaCFDIService endpoint with re (RFC emisor), rr (RFC receptor), tt (total, formatted per Anexo 20 — non-significant zeros are omitted, so 0.99, 1.0, or 2010.01, not a blanket two-decimal string) and id (UUID), and you get back an estado plus a “Cancelable” status. The strict two-decimal, string-exact rule from earlier is for your own XML-vs-extraction reconcile — the query param follows the Anexo 20 shape instead. Separately, cross-check the supplier’s RFC against the Listado Completo 69-B open-data CSV. The 69-B statuses that exist are exactly Presunto, Desvirtuado, Definitivo and Sentencia Favorable — don’t invent others.
A supplier at Presunto is a yellow flag. Definitivo means the SAT has determined the supplier issues operations without substance — those CFDI can’t be deducted, full stop. That’s the status your monitor watches for.
THE scheduled 69-B monitor: cron the list, diff it, alert on Definitivo
This is the automation the vendor tools charge for, and it’s twenty lines. Here’s why it matters: the 69-B is retroactive. A supplier you paid and deducted six months ago can be published as Definitivo today — and now the deduction you already booked is exposed. You cannot catch that at booking time, because at booking time they were clean.
So you run a cron that re-downloads the Listado Completo 69-B, diffs it against the last run, and alerts the moment any supplier you’ve already booked appears — or advances — to Definitivo.
import csv, io, json, pathlib, urllib.request
LIST_URL = "http://omawww.sat.gob.mx/cifras_sat/Documents/Listado_Completo_69-B.csv"
STATE = pathlib.Path("69b_last.json")
def download_69b() -> dict[str, str]:
# RFC -> current situacion (Presunto / Desvirtuado / Definitivo / Sentencia Favorable)
# Note: the SAT file has a preamble row and latin-1 encoding — adjust the
# header keys to the current publication before shipping this verbatim.
raw = urllib.request.urlopen(LIST_URL).read().decode("latin-1")
reader = csv.DictReader(io.StringIO(raw))
return {r["RFC"].strip().upper(): r["Situación del contribuyente"].strip()
for r in reader if r.get("RFC")}
def monitor(booked_rfcs: set[str]) -> list[dict]:
current = download_69b()
previous = json.loads(STATE.read_text()) if STATE.exists() else {}
alerts = []
for rfc in booked_rfcs:
now, before = current.get(rfc), previous.get(rfc)
if now == "Definitivo" and before != "Definitivo":
alerts.append({"rfc": rfc, "from": before, "to": now,
"risk": "retroactive — you already booked this supplier"})
STATE.write_text(json.dumps(current))
return alerts
Wire monitor() into a daily cron, feed it the set of RFCs you’ve actually booked, and route any alert to whoever signs off on provisions. The diff-against-last-run is what turns a static list into a monitor — you only page a human when a booked supplier’s status genuinely worsened.
- Re-download the Listado Completo 69-BDatos Abiertos CSV — full list, latin-1 encoded
- Diff against last runCompare current situación to the stored snapshot
- Filter to booked suppliersOnly RFCs you've actually paid/deducted matter
- Alert on flip to DefinitivoRetroactive exposure on invoices already in the ledger
- Persist the new snapshotNext run diffs against today — the loop closes
A clean architecture: extract → validate → reconcile → book or hold
Stack the pieces and the whole pipeline is four stages with one crossing point between “probabilistic” and “exact.” The LLM lives only in stage one.
Booking is downstream of everything. And it feeds the same ledger your outbound invoicing writes to — if you also emit CFDI from Stripe or Mercado Pago, the auto-invoice-CFDI flow is the mirror image of this one: one pipeline validates what comes in, the other stamps what goes out. Payables and receivables sharing a single source of UUID truth is what makes reconciliation at month-end trivial instead of a spreadsheet nightmare. For the broader picture of CFDI in your stack, the Mexican CFDI hub collects the surrounding pieces.
You’re an engineer, not a contador
Be blunt with yourself about what this pipeline decides and what it doesn’t. It decides: does this document’s identity and amount match a real, valid CFDI, and is the supplier clean today. That’s a mechanical, auditable set of checks — perfect for code.
What it does not decide: whether the expense is deductible, whether the IVA is acreditable, how to classify the concept, or what to do when a booked supplier flips to Definitivo. Those are judgment calls your accountant signs off on, and the monitor exists precisely to put that decision in front of them fast.
So ship the extraction, ship the deterministic reconciliation, ship the cron. Then verify every SAT behavior yourself — ConsultaCFDIService, the 69-B statuses, the open-data CSV — against the primary sources, because the SAT changes formats without notice and your ledger has to survive it.
Sources: SAT ConsultaCFDIService and Datos Abiertos 69-B (sat.gob.mx), Claude Structured Outputs (docs.claude.com), and the open phpcfdi tooling (github.com/phpcfdi) for reference CFDI parsing.