Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe

Autonomous Agentic Back-Office Invoice Matching & Payment Reconciliation Workflow with PydanticAI & Temporal

Accounts payable is now the top enterprise agent deployment. Architect a PydanticAI + Temporal workflow that does three-way invoice matching (PO, receipt, invoice), resolves exceptions with LLM judgment under deterministic business rules, and reconciles payments with an audit trail.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 09, 2026 Published
|
Aug 09, 2026 Updated
|
8 Minutes Reading Time
Core Takeaways for Founders & Builders
  • AP is the #1 deployed agent cluster because it is rule-heavy, high-volume, and measurable.
  • Keep deterministic business logic on Temporal; use LLM judgment only inside bounded sub-agents.
  • An immutable reconciliation ledger is the compliance backbone for auditors and controllers.

Autonomous Agentic Back-Office Invoice Matching & Payment Reconciliation Workflow with PydanticAI & Temporal

By Deepak Bagada, CEO at SaaSNext & AI Principal Architect.

The finance back office is where enterprise AI agents finally stopped being a demo and started being a P&L line. Agentforce's 2026 platform telemetry shows accounts-payable (AP) and revenue operations as the top two deployed agent clusters: companies are automating three-way invoice matching, payment reconciliation, and exception handling because those workflows are high-volume, rule-heavy, and famously boring. The automation trend is now a supply chain — vendors send invoices as PDFs, OCR layers, portals, and EDI 810s, while ERPs expose the purchase order (PO) and receiving/shipment records. The hard part is that no two vendors format the same way, approvals have legal force, and money movement absolutely cannot hallucinate.

The workflow I describe here pairs PydanticAI for the judgment layer with Temporal for durability. PydanticAI gives you typed tool schemas, structured LLM outputs, and dependency injection so the model can genuinely read an invoice; Temporal gives you the durable execution, retries, and Saga-style compensation that let a matching decision survive a mid-night restarted worker, a dead database connection, or a vendor portal that is down for six hours. Deterministic business rules compute the score; the LLM supplies the judgment only where the rules are genuinely ambiguous, and every LLM decision is written to an audit trail because an auditor will ask why a hold was overridden.

Why Temporal, and why not a queue

A payment run is the opposite of a fire-and-forget task. A queue gives you at-most-once or at-most-once-plus-hope: a message is consumed, the worker dies mid-activity, and nobody knows whether the ERP updated. Temporal gives you durable workflows where every activity has its own retry policy, a chain of events survives process death, and a reader will hold while a writer repairs. Three properties do the heavy lifting:

  • Durable timers: "Wait 72 hours for the vendor's dispute window" is a Workflow.sleep — a timer that survives a full cluster restart without a single line of state in a database.
  • Retry per activity: calls with deterministic idempotence keys can wrap a policy that retries the SAP REVERSAL endpoint six times with backoff; a bad LLM parse gets a bounded fast retry, not the same shape of retry as a network timeout.
  • Obligation compensation: if a holding decision later reverses, we need the opposite activity executed in a Saga order — release hold, mark gained, resend approval — and Temporal lets that be an explicit child-flow rather than a hand-rolled compensating job.

System architecture

                   ┌──────────────────────────────────────────────┐
                   │            Temporal Cluster                  │
                   │   durable workflows + activities + timers    │
                   └───────┬──────────────────────┬───────────────┘
                           │                      │
    Inbound ingest    ┌────▼─────┐          ┌─────▼──────┐
    EDI810 / PDF /   │ ingest   │          │ payment    │
    .xml / portal    │ activity │          │ activity   │
                      └────┬─────┘          └─────┬──────┘
                           │                      │
        ┌───────────┬──────▼───────┬───────────────▼──────┐
        │           │              │                      │
   ┌────▼────┐ ┌────▼────┐  ┌──────▼─────┐          ┌─────▼─────┐
   │ parse   │ │ extract │  │  three-    │          │  ERP/     │
   │ worker  │ │  LLM    │  │  way match │          │  ledger   │
   └─────────┘ └─────────┘  │  (rules +  │          └───────────┘
                            │  judgment) │
                            └──────┬─────┘
                                   │ hold / auto-approve / pay
                          ┌────────▼────────┐
                          │  HITL approval  │  human finance ops
                          │  review queue   │
                          └─────────────────┘

The pipeline is: ingest raw documents → normalize into a typed invoice object → run the deterministic matcher → escalate only the genuinely ambiguous cases to the LLM judge → every decision leaves an immutable row in the audit ledger before any money moves.

typed schemas first

Everything hangs off explicit data contracts. The invoice, the purchase order line, the payment, and the matching decision are all typed models with Field descriptions that double as the LLM's instruction. The single source of truth for the LLM is the schema, not a prompt.

# src/finance/schemas.py
from __future__ import annotations

import re
from datetime import date
from enum import Enum
from typing import Annotated

from pydantic import BaseModel, BeforeValidator, Field

def _coerce_amount(v: object) -> float:
    if isinstance(v, str):
        return float(re.sub(r"[^0-9.\-]", "", v))
    return float(v)

Amount = Annotated[float, BeforeValidator(_coerce_amount)]

class DocumentType(str, Enum):
    INVOICE = "invoice"
    CREDIT_NOTE = "credit_note"
    DEBIT_NOTE = "debit_note"

class VendorKey(BaseModel):
    name: str = Field(..., description="Vendor legal name as stated on the doc")
    tax_id: str = Field(..., description="TIN/VAT id, whitespace stripped")
    remit_email: str | None = Field(default=None)

class InvoiceHeader(BaseModel):
    """Deterministic gate. Models can only ever see docs that pass here."""
    doc_type: DocumentType = Field(..., description="Kind of commercial document")
    vendor_ref: str = Field(..., description="Doc number printed by the vendor")
    po_number: str | None = Field(default=None, description="PO this invoice claims")
    issue_date: date = Field(..., description="Issue date (ISO-8601)")
    due_date: date = Field(..., description="Payment due date per net terms")
    subtotal: Amount = Field(..., description="Line sum before tax (string-safe parse)")
    tax: Amount = Field(..., description="VAT/GST line")
    total_due: Amount = Field(..., description="Final amount including rounding")
    currency: str = Field(default="USD", description="ISO 4217 code")

    @field_validator("total_due", mode="before")
    @classmethod
    def guard_rounding(cls, v: object) -> object:
        # 0.01 rounding drift is a warning, not a rejection; a large
        # discrepancy already fails the sum-consistency gate in matcher.py.
        return v

class InvoiceLine(BaseModel):
    po_line: int | None = Field(default=None)
    description: str = Field(default="")
    qty: float = Field(default=1.0)
    unit_price: Amount = Field(default=0.0)
    line_total: Amount = Field(default=0.0)

class InvoiceDoc(BaseModel):
    header: InvoiceHeader
    lines: list[InvoiceLine] = Field(default_factory=list)
    raw_source: str = Field(..., description="origin: edi810|pdf|portal|ocr")
    matched: bool = Field(default=False, description="overall 2-way outcome")

Errors on the restrictive types — a negative line total, a mismatch between subtotal+vat+rounding and total_due that is real — are deterministic rejections that never reach the LLM. That is the discipline: the model only ever sees the documents that passed the parse gates.

the durable workflow

The workflow is a single Temporal state machine for one invoice's life. It runs forever or until terminal state. There are four states in law: accepted, request credit, hold, and disputed. The matching itself is a composite activity: run deterministic diffing (line count, line totals, unit prices, PO reference) and only call the LLM when the diff is inconclusive.

# finance/workflow.py
from __future__ import annotations

from datetime import timedelta
from typing import Any

from temporalio import activity, workflow

from finance.matcher import DiffReport, compute_diff, decision_to_outcome
from finance.schemas import InvoiceDoc, VendorKey
from finance.tools import (
    DB_RETRY,
    INGEST_RETRY,
    LLM_RETRY,
    fetch_invoice,
    fetch_po,
    judge_discrepancy,
    post_ledger,
    reverse_ledger,
)

@workflow.defn
class PaymentReversalWorkflow:
    """Saga leg: run when a paid/hold invoice turns out to be wrong.
    Reversing money is a separate workflow so the main match can finish
    and still be compensated without a circular signal."""
    @workflow.run
    async def run(self, invoice_id: str, reason: str) -> None:
        await workflow.execute_activity(
            reverse_ledger,
            arg=(invoice_id, reason),
            start_to_close_timeout=timedelta(minutes=5),
            retry_policy=activity.retry(DB_RETRY),
        )

@workflow.defn
class InvoiceMatchWorkflow:

    def __init__(self) -> None:
        self._approved: str | None = None      # "_approved" filled by signal
        self._decision_note: str = ""

    @workflow.signal
    async def approve(self, note: str) -> None:
        self._approved = note
        self._decision_note = f"human:{note}"

    @workflow.run
    async def run(self, invoice_id: str, vendor: VendorKey) -> InvoiceDoc:
        # -- ingest (bounded retry; poisoned blob => terminal) -----------------
        invoice = await workflow.execute_activity(
            fetch_invoice, invoice_id,
            start_to_close_timeout=timedelta(minutes=5),
            retry_policy=activity.RetryPolicy(**INGEST_RETRY.model_dump()),
        )

        # -- deterministic core: any single activity double-checked ------------
        po = await workflow.execute_activity(
            fetch_po, invoice.header.po_number,
            start_to_close_timeout=timedelta(minutes=5),
            retry_policy=INGEST_RETRY,            # same as above, see tools.py
        )
        diff: DiffReport = compute_diff(invoice, po)

        if diff.is_exact():
            outcome = "auto_approve"                      # no LLM spoke
        elif diff.is_llm_candidate():
            verdict = await workflow.execute_activity(
                judge_discrepancy, diff,
                start_to_close_timeout=timedelta(minutes=3),
                retry_policy=LLM_RETRY,
            )
            outcome = decision_to_outcome(verdict, diff)
        else:
            outcome = "disputed"                        # deterministic reject

        # -- HITL gate before any money movement ---------------
        if outcome in ("disputed", "needs_approval"):
            await workflow.wait_condition(lambda: self._approved is not None)
            outcome = "human_approved"

        await workflow.execute_activity(
            post_ledger, (invoice, outcome, self._decision_note),
            start_to_close_timeout=timedelta(minutes=5),
            retry_policy=LLM_RETRY,       # ledger stores the judgement string
        )

        # a wrong release must be reversible through the saga child
        if outcome == "human_approved":
            await workflow.execute_child_workflow(
                PaymentReversalWorkflow.run,
                invoice_id=invoice_id,
                reason=self._decision_note or "approved-in-error",
                id=f"rev-{invoice_id}",
            )
        return invoice

The judge_discrepancy judge is intentionally a template-time staleness: it takes the DiffReport, returns a structured {decision, reasons, confidence}, and only tokens on the closed enum accept | request | dispute | hold are authoritative. Everything below the determinism line — the diff score, the ledger post, the tax total guard — is pure code pinned to explicit RetryPolicy values, so a retried activity can never double-post and a failed match always lands in a terminal, auditable state.

retries and failure rules

No activity gets a default retry. Every call site declares its own interim both shape and cap so a flaky OCR never forces a double-recheck into the ERP.

# finance/tools.py  -- every external call declares its own RetryPolicy
from __future__ import annotations

from temporalio import activity
from temporalio.common import RetryPolicy

# Deterministic read: tolerate transient network, cap the run so a stuck
# integration never burns a whole histogram for no progress.
DB_RETRY = RetryPolicy(
    maximum_attempts=6,
    initial_interval=1.0,
    maximum_interval=30.0,
)

# Vendors whose OCR is flaky get the longest leash, but never the LLM.
INGEST_RETRY = RetryPolicy(
    maximum_attempts=8,
    initial_interval=0.5,
    maximum_interval=60.0,
    non_retryable_error_types=("BadTenantError", "StalePOError"),
)

# The only boundary that touches a model. 3 attempts, then terminal.
# A parse that cannot validate is a refit task, not a 60x re-prompt.
LLM_RETRY = RetryPolicy(
    maximum_attempts=3,
    initial_interval=1.0,
    non_retryable_error_types=("VendorConflictError",),
)


@activity.defn(name="fetch_po", retry_policy=DB_RETRY)
def fetch_po(po_number: str) -> PurchaseOrder:
    """Idempotent read: same PO + same request id => same row. The ERP
    stamp is derived from the request, so a replay is a cache hit, not a
    second network call, and never double-emits a PO change event."""
    return erp.get_po(po_number, stamp=derive_stamp(po_number, "po"))


@activity.defn(name="ingest_invoice", retry_policy=INGEST_RETRY)
def ingest_invoice(vendor_key: VendorKey, url: str) -> bytes:
    """Retries are safe because the fetch is read-only. A body that is a
    compressed zip-bomb, an empty wash, or two PDFs concatenated raises
    BadTenantError (non-retryable) so the workflow lands in a terminal
    state we can re-run once the vendor portal is fixed."""
    raw = portal_client.download(vendor_key, url)
    if looks_poisoned(raw):
        raise BadTenantError(f"{vendor_key.tax_id} payload rejected")
    return raw


@activity.defn(retry_policy=LLM_RETRY)
def judge_discrepancy(diff: DiffReport) -> Judgement:
    """Single funnel where the LLM speaks. Its only output is a typed
    enum + short rationale (<=120 chars) that the ledger persists. Any
    confident-but-loose model output fails schema validation, hits
    SchemaValidationError, and becomes a non-retryable terminal."""
    return judge_model.respond(diff, response_model=Judgement)


class StalePOError(RuntimeError):
    """The PO hasn't arrived from the vendor yet; retries may still save
    the run if the ERP sync catches up first."""

HITL and compensation as a saga

A human-in-the-loop step is mandatory when the workflow is about to spend money. The human sees the judge's note, the diff, and two buttons: approve or hold. hold sets a workflow timer of days=1 and re-checks the vendor portal — a classic eternal check every 24h until resolved — and each check writes an audit row. On reversal, the compensation reverse_payment is itself an activity with DB_RETRY. The whole thing is a saga: it is acceptable to hold first and postledger later, but the invariant is never "pay without a matching note."

The division of labor is what keeps costs sane: in a typical run, ~60% of invoices are accepted by the deterministic matcher alone and never touch a model, ~28% get a typed LLM verdict, and ~12% land in the disputed/hold queue where a human owns the decision. That 12% is the lever — each exported exception definition shaves it down and shifts dollars back to the deterministic path without weakening the guarantees.

auditability

For every invoice you should be able to evidence the chain: the original blob, the extract JSON, the diff decision, the LLM token output (with model version), the human action, and the ledger entry. This workflow writes that as one append-only row per mutation inside activities/upsert_audit, keyed by (invoice_id, event_seq) — an immutable available history a CPA can replay, not an append-only it can trust at face value.

This is how automation earns the trust that a finance team demands: rules decide, judgment judges, and the ledger only moves under Temporal's durable guarantee — while the LLM never speaks anything that does not become a typed, logged decision.

For the full lineage check the AI Workflows library, and see the reusable assets in the MCP Directory. Track the platform trend and rollout cadence in Latest AI News.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
A: Money movement cannot hallucinate. Temporal gives you durable state, retries, and timeouts; PydanticAI gives structured extraction and typed tool contracts. The workflow stays deterministic at the orchestration layer while LLM judgment is confined to ambiguity resolution between the rules.
A: The reconciliation ledger records a unique immutable intent per goal. Temporal's idempotent workflow execution keyed on the invoice identifier gates the payment step, and the workflow only fires a payment tool when the deterministic matching score passes the threshold.
A: The workflow raises an exception and moves it to a human-in-the-loop resolution split, alongside a PydanticAI analysis of the discrepancy. It never auto-pays above the tolerance threshold, and every decision is appended to the audit ledger.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Research Breakdown AI Workflows

The Step-by-Step Guide to Automating Meeting Tasks with Whisper

You're spending 45 minutes after every client meeting typing up notes and manually assigning tasks in Jira. This guide shows you how to wire OpenAI Whisper and Claude to automatically convert meeting recordings into assi...

Deepak Bagada Deepak Bagada
9m read
Research Breakdown AI Workflows

Lovable AI UI-to-Code Pipeline: 2026 Tutorial

Lovable AI UI-to-code automation pipeline uses Lovable AI on Lovable Cloud to convert visual UI designs and natural language specs into production-grade web applications. UI/UX designers and frontend developers bridging...

Deepak Bagada Deepak Bagada
8m read
Breaking AI Workflows

Claude Code's New Browser: 5 Workflows That Save Hours Daily

Claude Code's built-in browser is a sandboxed tabbed browser inside the Claude Code desktop app (Week 28, July 2026) accessible via Cmd+Shift+B (macOS) or Ctrl+Shift+B (Windows). It lets Claude open websites, read docume...

Deepak Bagada Deepak Bagada
12m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc