Skip to main content
Back to Blog
AP AutomationLangGraphn8nPostgreSQLOllamaNext.jsFintechAI AgentsStripeCashfreeAWSDocker

Architecting Autonomous AP Automation in 2026: Financial Reconciliation Agents with n8n, LangGraph, and PostgreSQL

A hands-on blueprint for building a zero-touch Accounts Payable automation SaaS in 2026. Learn privacy-first invoice extraction with LangGraph and Ollama, vendor routing with self-hosted n8n, an append-only PostgreSQL ledger, a Human-on-the-Loop Next.js dashboard, and B2B payouts via Stripe and Cashfree, all deployed on AWS with Docker.

November 1, 202626 min readNiraj Kumar

Hero illustration of an autonomous AP pipeline: invoices flowing through AI agents into a ledger and out as vendor payments

Introduction: Why AP Is the Most Underrated AI Opportunity of 2026

Every company pays its vendors, and almost every company still does it badly.

Walk into the finance team of a mid-market business today and you will still find someone downloading PDF invoices from a shared inbox, keying line items into an ERP, chasing approvers on Slack, and cross-checking purchase orders against goods receipts in a spreadsheet. Then, once a week, someone exports a payment batch and uploads it to a bank portal while praying they did not pay the same invoice twice.

That process is slow, error-prone, and, crucially, extremely well-defined. And well-defined, high-volume, rules-plus-judgment work is exactly where AI agents shine in 2026.

The shift this year is not "AI can read invoices." OCR plus templates have done that for a decade. The shift is that agentic systems can now own the whole reconciliation loop: extract, validate, three-way match, route for approval, pay, and record, while escalating only the genuinely weird cases to a human. Finance leaders have stopped asking for "invoice capture" and started asking for "zero-touch AP."

In this guide, I will walk you through a production-grade blueprint I have used and refined for building exactly that as a SaaS product. We will cover:

  • LangGraph + Ollama agents for privacy-first invoice parsing on local LLMs
  • Self-hosted n8n for vendor routing and approval workflows
  • Node.js REST APIs as the transactional backbone
  • PostgreSQL as an append-only, hash-chained financial ledger
  • A Next.js + TypeScript + Shadcn UI "Human-on-the-Loop" exception dashboard
  • Stripe and Cashfree for B2B disbursements
  • Docker on AWS to ship it all

This is a long one. Grab a coffee.

The Core Concept: Agents Propose, Code Disposes

Before any code, let me share the single most important design principle in this entire architecture, because getting it wrong is how fintech AI projects end up in post-mortems:

The LLM never makes a financial decision. It only produces structured claims that deterministic code verifies.

An agent can say "this invoice is from Acme Corp, total 12,450.00 USD, referencing PO-8812." It cannot say "therefore pay it." The decision to pay comes from a rules engine checking that the PO exists, the amounts match within tolerance, the vendor's bank details have not changed, and the approval policy is satisfied.

This gives you the flexibility of AI for messy inputs and the predictability of software for money movement. Auditors love it. Your on-call engineer loves it even more.

Human-in-the-Loop vs. Human-on-the-Loop

These two terms get used interchangeably, but they describe very different products:

ModelWho actsHuman roleTypical touch rate
Human-in-the-LoopHuman, assisted by AIApproves every item100%
Human-on-the-LoopAI, supervised by humanResolves exceptions only5–15%
Fully autonomousAIAudits after the fact~0%

We are building Human-on-the-Loop. Agents process the happy path end to end; humans see only exceptions, plus periodic sampling for quality assurance.

High-Level Architecture

Here is the system at a glance:

flowchart LR
    subgraph Ingest
        A[Email / SFTP / Vendor Portal] --> B[n8n Ingestion Workflow]
    end

    subgraph AgentLayer[Agent Layer - Python]
        C[LangGraph Orchestrator]
        D[Extraction Agent]
        E[Validation Agent]
        F[Matching Agent]
        G[(Ollama - Local LLM)]
        C --> D --> E --> F
        D -.-> G
        F -.-> G
    end

    subgraph Core[Core Services - Node.js]
        H[AP REST API]
        I[(PostgreSQL Ledger)]
        H --> I
    end

    subgraph Ops
        J[n8n Routing & Approvals]
        K[Next.js Exception Dashboard]
    end

    subgraph Pay[Disbursement]
        L[Stripe Connect]
        M[Cashfree Payouts]
    end

    B --> H
    H --> C
    F --> H
    H --> J
    J --> K
    K --> H
    J --> H
    H --> L
    H --> M

Each layer has one job:

  • n8n handles the edges: ingestion triggers, notifications, approval routing, scheduled payment runs.
  • LangGraph handles reasoning: extraction, validation, matching, with checkpoints and interrupts.
  • Node.js API is the only component allowed to write to the ledger or move money.
  • PostgreSQL is the source of truth and the audit trail.
  • Next.js gives humans a focused view of what needs their attention.

Let us build it layer by layer.

Layer 1: The Immutable Ledger in PostgreSQL

I start with the database because in fintech, your data model is your architecture. If the ledger is sound, you can rewrite every other component later.

Event-Sourced Invoice States

Instead of an invoices table with a mutable status column, we record every state transition as an event. The current state is a projection.

sql
-- 001_ledger.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;
 
CREATE TYPE invoice_state AS ENUM (
  'RECEIVED', 'EXTRACTED', 'VALIDATED', 'MATCHED',
  'EXCEPTION', 'APPROVED', 'PAYMENT_PENDING', 'PAID', 'REJECTED'
);
 
CREATE TABLE invoices (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID NOT NULL,
  vendor_id       UUID,
  source_uri      TEXT NOT NULL,
  file_sha256     TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, file_sha256)          -- same file can't be ingested twice
);
 
CREATE TABLE invoice_events (
  seq             BIGSERIAL PRIMARY KEY,
  tenant_id       UUID NOT NULL,
  invoice_id      UUID NOT NULL REFERENCES invoices(id),
  state           invoice_state NOT NULL,
  actor           TEXT NOT NULL,           -- 'agent:extractor', 'user:42', 'n8n:approval'
  payload         JSONB NOT NULL,
  prev_hash       TEXT,
  hash            TEXT NOT NULL,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_events_invoice ON invoice_events (invoice_id, seq);

Making It Append-Only and Tamper-Evident

Two things make this table trustworthy. First, a trigger that refuses updates and deletes. Second, a hash chain where each event commits to the previous one, so any retroactive edit breaks the chain.

sql
-- 002_immutability.sql
CREATE OR REPLACE FUNCTION forbid_mutation() RETURNS trigger AS $$
BEGIN
  RAISE EXCEPTION 'invoice_events is append-only';
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER trg_no_update BEFORE UPDATE OR DELETE ON invoice_events
  FOR EACH ROW EXECUTE FUNCTION forbid_mutation();
 
CREATE OR REPLACE FUNCTION chain_hash() RETURNS trigger AS $$
DECLARE
  last_hash TEXT;
BEGIN
  -- Serialize chain writes per tenant to avoid forks
  PERFORM pg_advisory_xact_lock(hashtext(NEW.tenant_id::text));
 
  SELECT hash INTO last_hash
  FROM invoice_events
  WHERE tenant_id = NEW.tenant_id
  ORDER BY seq DESC LIMIT 1;
 
  NEW.prev_hash := COALESCE(last_hash, 'GENESIS');
  NEW.hash := encode(
    digest(
      NEW.prev_hash || NEW.invoice_id::text || NEW.state::text ||
      NEW.actor || NEW.payload::text || NEW.created_at::text,
      'sha256'
    ), 'hex');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;
 
CREATE TRIGGER trg_chain BEFORE INSERT ON invoice_events
  FOR EACH ROW EXECUTE FUNCTION chain_hash();
 
-- Application role can only INSERT and SELECT
REVOKE UPDATE, DELETE, TRUNCATE ON invoice_events FROM ap_app;
GRANT INSERT, SELECT ON invoice_events TO ap_app;

A nightly job can recompute the chain and alert on mismatches. For extra assurance, periodically anchor the latest hash in an external system (an S3 bucket with Object Lock in compliance mode works nicely).

The Current-State Projection

sql
CREATE VIEW invoice_current AS
SELECT DISTINCT ON (e.invoice_id)
  e.invoice_id, e.tenant_id, e.state, e.payload, e.created_at AS state_since
FROM invoice_events e
ORDER BY e.invoice_id, e.seq DESC;

Add row-level security on tenant_id and you have multi-tenant isolation enforced by the database itself rather than by hopeful WHERE clauses.

Layer 2: Privacy-First Extraction Agents with LangGraph and Ollama

Invoices contain bank account numbers, tax IDs, negotiated pricing, and sometimes employee names. Sending all of that to a third-party API is a non-starter for many enterprise buyers, especially in regulated industries and in regions with strict data residency rules. Running models locally with Ollama inside your own VPC sidesteps the whole conversation.

Choosing Models

In practice, a two-model setup works well:

  • A vision-capable model (for example, a Qwen-VL or Llama vision variant) for scanned and photographed invoices
  • A strong text model for reasoning over OCR output and PO matching

Benchmark on your customers' invoices. Model rankings change monthly; your invoice distribution does not.

Defining the Agent State

LangGraph models the workflow as a typed state machine. Each node reads and writes part of the state, and the checkpointer persists it after every step, which is what lets us pause for humans and resume days later.

python
# agents/state.py
from typing import TypedDict, Optional, Literal
from pydantic import BaseModel, Field
from decimal import Decimal
 
class LineItem(BaseModel):
    description: str
    quantity: Decimal
    unit_price: Decimal
    amount: Decimal
 
class InvoiceExtraction(BaseModel):
    vendor_name: str
    vendor_tax_id: Optional[str] = None
    invoice_number: str
    invoice_date: str = Field(description="ISO 8601 date")
    currency: str = Field(min_length=3, max_length=3)
    po_number: Optional[str] = None
    subtotal: Decimal
    tax_total: Decimal
    grand_total: Decimal
    line_items: list[LineItem]
    confidence: float = Field(ge=0, le=1)
 
class APState(TypedDict):
    invoice_id: str
    tenant_id: str
    ocr_text: str
    extraction: Optional[InvoiceExtraction]
    validation_errors: list[str]
    match_result: Optional[dict]
    decision: Optional[Literal["auto_approve", "exception", "reject"]]
    human_resolution: Optional[dict]

The Extraction Node

The trick to reliable local extraction is schema-constrained decoding. Ollama supports passing a JSON schema to the format parameter, which forces the model to emit valid JSON matching your Pydantic model.

python
# agents/nodes/extract.py
from langchain_ollama import ChatOllama
from agents.state import APState, InvoiceExtraction
 
llm = ChatOllama(
    model="qwen2.5:14b-instruct",
    base_url="http://ollama:11434",
    temperature=0,
    format=InvoiceExtraction.model_json_schema(),
)
 
SYSTEM = """You are an accounts payable extraction engine.
Extract ONLY what is printed on the invoice. Never infer or invent values.
If a field is missing, use null. Report your honest confidence from 0 to 1."""
 
def extract_node(state: APState) -> dict:
    response = llm.invoke([
        ("system", SYSTEM),
        ("human", f"Invoice text:\n\n{state['ocr_text'][:24000]}"),
    ])
    extraction = InvoiceExtraction.model_validate_json(response.content)
    return {"extraction": extraction}

Note temperature=0 and the explicit "never invent" instruction. Hallucinated invoice numbers are the most common failure mode I see with naive setups.

The Deterministic Validation Node

No LLM here. Just arithmetic and business rules.

python
# agents/nodes/validate.py
from decimal import Decimal
from agents.state import APState
 
TOLERANCE = Decimal("0.02")
 
def validate_node(state: APState) -> dict:
    x = state["extraction"]
    errors: list[str] = []
 
    line_sum = sum((li.amount for li in x.line_items), Decimal("0"))
    if abs(line_sum - x.subtotal) > TOLERANCE:
        errors.append(f"Line items sum {line_sum} != subtotal {x.subtotal}")
 
    if abs(x.subtotal + x.tax_total - x.grand_total) > TOLERANCE:
        errors.append("Subtotal + tax does not equal grand total")
 
    for li in x.line_items:
        if abs(li.quantity * li.unit_price - li.amount) > TOLERANCE:
            errors.append(f"Line '{li.description[:40]}' qty x price mismatch")
 
    if x.confidence < 0.85:
        errors.append(f"Low extraction confidence: {x.confidence:.2f}")
 
    return {"validation_errors": errors}

Three-Way Matching

The matching node pulls the PO and goods receipt from your API and compares quantities and prices. An LLM helps with one narrow job: fuzzy-matching line descriptions ("HP LaserJet Toner 58A Blk" vs. "Toner cartridge, black, CF258A"). The final match verdict is still computed in code.

python
# agents/nodes/match.py
import httpx
from agents.state import APState
 
API = "http://ap-api:4000/internal"
 
def match_node(state: APState) -> dict:
    x = state["extraction"]
    if not x.po_number:
        return {"match_result": {"status": "NO_PO"}, "decision": "exception"}
 
    with httpx.Client(timeout=10) as client:
        po = client.get(f"{API}/purchase-orders/{x.po_number}",
                        headers={"x-tenant-id": state["tenant_id"]}).json()
        grn = client.get(f"{API}/goods-receipts", params={"po": x.po_number},
                         headers={"x-tenant-id": state["tenant_id"]}).json()
 
    variance = abs(float(x.grand_total) - po["total"]) / max(po["total"], 1)
    received_ok = all(r["qty_received"] >= r["qty_ordered"] for r in grn["lines"])
 
    status = "MATCHED" if variance <= 0.01 and received_ok else "VARIANCE"
    decision = "auto_approve" if status == "MATCHED" and not state["validation_errors"] else "exception"
 
    return {
        "match_result": {"status": status, "variance": variance, "received_ok": received_ok},
        "decision": decision,
    }

Wiring the Graph with Human-on-the-Loop Interrupts

This is where LangGraph earns its place. The interrupt() primitive pauses the graph, persists state to Postgres via the checkpointer, and waits. When a human resolves the exception in the dashboard, we resume the exact same thread with their decision.

python
# agents/graph.py
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt
from langgraph.checkpoint.postgres import PostgresSaver
from agents.state import APState
from agents.nodes.extract import extract_node
from agents.nodes.validate import validate_node
from agents.nodes.match import match_node
 
def human_review_node(state: APState) -> dict:
    resolution = interrupt({
        "invoice_id": state["invoice_id"],
        "errors": state["validation_errors"],
        "match": state["match_result"],
        "extraction": state["extraction"].model_dump(mode="json"),
    })
    # resolution comes from the dashboard: {"action": "approve" | "reject", "corrections": {...}}
    return {
        "human_resolution": resolution,
        "decision": "auto_approve" if resolution["action"] == "approve" else "reject",
    }
 
def route_after_match(state: APState) -> str:
    return "human_review" if state["decision"] == "exception" else "finalize"
 
def finalize_node(state: APState) -> dict:
    # Hand the verdict to the Node.js API, which owns the ledger.
    import httpx
    httpx.post("http://ap-api:4000/internal/agent-verdicts", json={
        "invoice_id": state["invoice_id"],
        "tenant_id": state["tenant_id"],
        "decision": state["decision"],
        "extraction": state["extraction"].model_dump(mode="json"),
        "match": state["match_result"],
        "human_resolution": state.get("human_resolution"),
    }, timeout=10).raise_for_status()
    return {}
 
builder = StateGraph(APState)
builder.add_node("extract", extract_node)
builder.add_node("validate", validate_node)
builder.add_node("match", match_node)
builder.add_node("human_review", human_review_node)
builder.add_node("finalize", finalize_node)
 
builder.add_edge(START, "extract")
builder.add_edge("extract", "validate")
builder.add_edge("validate", "match")
builder.add_conditional_edges("match", route_after_match, ["human_review", "finalize"])
builder.add_edge("human_review", "finalize")
builder.add_edge("finalize", END)
 
def build_graph(conn_string: str):
    checkpointer = PostgresSaver.from_conn_string(conn_string)
    return checkpointer, builder

Resuming is a one-liner from a small FastAPI wrapper:

python
from langgraph.types import Command
 
graph.invoke(Command(resume={"action": "approve", "corrections": {}}),
             config={"configurable": {"thread_id": invoice_id}})

Using the invoice ID as the thread_id gives you a clean one-to-one mapping between business entities and agent runs.

Here is the full lifecycle as a state diagram:

stateDiagram-v2
    [*] --> RECEIVED
    RECEIVED --> EXTRACTED: Extraction agent
    EXTRACTED --> VALIDATED: Rules pass
    EXTRACTED --> EXCEPTION: Math or confidence fails
    VALIDATED --> MATCHED: 3-way match OK
    VALIDATED --> EXCEPTION: Variance / No PO
    EXCEPTION --> APPROVED: Human approves
    EXCEPTION --> REJECTED: Human rejects
    MATCHED --> APPROVED: Policy auto-approve
    APPROVED --> PAYMENT_PENDING: n8n payment run
    PAYMENT_PENDING --> PAID: Webhook confirmed
    PAID --> [*]
    REJECTED --> [*]

LangGraph agent graph visualized with extract, validate, match, human review and finalize nodes, showing the interrupt checkpoint

Layer 3: The Node.js REST API, the Only Thing That Touches Money

The API is deliberately boring. It validates input with Zod, writes events to the ledger in transactions, and is the only service holding payment gateway credentials.

typescript
// api/src/routes/verdicts.ts
import { FastifyInstance } from "fastify";
import { z } from "zod";
import { pool } from "../db";
import { evaluateApprovalPolicy } from "../policy";
 
const Verdict = z.object({
  invoice_id: z.string().uuid(),
  tenant_id: z.string().uuid(),
  decision: z.enum(["auto_approve", "exception", "reject"]),
  extraction: z.record(z.any()),
  match: z.record(z.any()).nullable(),
  human_resolution: z.record(z.any()).nullable().optional(),
});
 
export async function verdictRoutes(app: FastifyInstance) {
  app.post("/internal/agent-verdicts", async (req, reply) => {
    const v = Verdict.parse(req.body);
    const client = await pool.connect();
 
    try {
      await client.query("BEGIN");
      await client.query("SELECT set_config('app.tenant_id', $1, true)", [v.tenant_id]);
 
      // Duplicate-invoice fingerprint check
      const dup = await client.query(
        `SELECT 1 FROM invoice_current
         WHERE tenant_id = $1
           AND payload->'extraction'->>'invoice_number' = $2
           AND payload->'extraction'->>'vendor_name' = $3
           AND state IN ('APPROVED','PAYMENT_PENDING','PAID')`,
        [v.tenant_id, v.extraction.invoice_number, v.extraction.vendor_name]
      );
      if (dup.rowCount) throw new Error("DUPLICATE_INVOICE");
 
      // The agent suggests; policy decides.
      const policy = await evaluateApprovalPolicy(client, v);
      const state =
        v.decision === "reject" ? "REJECTED"
        : policy.requiresApprover ? "EXCEPTION"
        : "APPROVED";
 
      await client.query(
        `INSERT INTO invoice_events (tenant_id, invoice_id, state, actor, payload)
         VALUES ($1, $2, $3, $4, $5)`,
        [v.tenant_id, v.invoice_id, state, "agent:langgraph",
         { extraction: v.extraction, match: v.match, policy }]
      );
      await client.query("COMMIT");
 
      // Fire-and-forget to n8n for routing/notifications
      await fetch(process.env.N8N_ROUTING_WEBHOOK!, {
        method: "POST",
        headers: { "content-type": "application/json",
                   "x-signature": sign(v.invoice_id) },
        body: JSON.stringify({ invoiceId: v.invoice_id, state, policy }),
      });
 
      return reply.code(202).send({ state });
    } catch (err) {
      await client.query("ROLLBACK");
      throw err;
    } finally {
      client.release();
    }
  });
}

Notice that even when the agent says auto_approve, the API runs evaluateApprovalPolicy. An invoice over a tenant's threshold, from a vendor whose bank details changed in the last 30 days, or the first invoice ever from a new vendor will still go to a human. Vendor bank-detail changes are the number one vector for invoice fraud; never let an agent wave them through.

Layer 4: Vendor Routing and Approvals with Self-Hosted n8n

Why n8n and not more code? Because approval matrices change constantly. A new CFO wants everything over 50,000 to hit her desk. Procurement wants IT vendors routed to the IT director. Those are business decisions, and finance ops should be able to edit them in a visual workflow without filing a Jira ticket.

Self-hosting n8n (fair-code license, runs in Docker) also keeps invoice metadata inside your infrastructure.

A Typical Routing Workflow

  1. Webhook trigger receives the state change from the API (verify the HMAC signature in a Code node first).
  2. Switch node branches on state: EXCEPTION, APPROVED, REJECTED.
  3. For EXCEPTION, an HTTP Request node fetches the approval policy and resolves the approver chain (department head, then finance controller for high amounts).
  4. Slack / Microsoft Teams / Email nodes notify approvers with a deep link into the Next.js dashboard.
  5. A Wait node with a timeout escalates to the next approver if nobody acts within the SLA.
  6. For APPROVED, the invoice is added to the next scheduled payment run.

A separate Schedule Trigger workflow runs payment batches, for example twice a week, honoring early-payment discount terms like "2/10 net 30" by prioritizing invoices whose discount window closes soonest.

javascript
// n8n Code node: verify webhook signature and pick discount-eligible invoices
const crypto = require('crypto');
const secret = $env.AP_WEBHOOK_SECRET;
 
const body = JSON.stringify($json.body);
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex');
if (expected !== $json.headers['x-signature']) {
  throw new Error('Invalid signature');
}
 
const today = new Date();
return $input.all().filter(item => {
  const due = new Date(item.json.discountDeadline);
  const daysLeft = (due - today) / 86400000;
  return daysLeft >= 0 && daysLeft <= 3;
});

Note: depending on your n8n version and settings, you may need to allow built-in modules like crypto in Code nodes via the NODE_FUNCTION_ALLOW_BUILTIN environment variable.

Layer 5: The Human-on-the-Loop Exception Dashboard

The dashboard is not a general admin panel. It does one thing: help a human resolve an exception in under 30 seconds. Every pixel serves that goal.

What a Good Exception Card Shows

  • The original invoice, rendered side by side with extracted fields
  • The specific reason for the exception, in plain language ("Grand total is 3.2% above PO-8812")
  • The PO and goods receipt lines with mismatches highlighted
  • Vendor risk signals (new vendor, recent bank change)
  • Two primary actions: Approve and Reject, plus inline field correction

Server Component for the Queue

tsx
// app/(dashboard)/exceptions/page.tsx
import { Badge } from "@/components/ui/badge";
import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import { getExceptions } from "@/lib/api";
import { ResolveButtons } from "./resolve-buttons";
 
export const dynamic = "force-dynamic";
 
export default async function ExceptionsPage() {
  const exceptions = await getExceptions();
 
  return (
    <div className="space-y-4 p-6">
      <h1 className="text-2xl font-semibold tracking-tight">Exceptions</h1>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Vendor</TableHead>
            <TableHead>Invoice</TableHead>
            <TableHead className="text-right">Amount</TableHead>
            <TableHead>Reason</TableHead>
            <TableHead>Risk</TableHead>
            <TableHead />
          </TableRow>
        </TableHeader>
        <TableBody>
          {exceptions.map((ex) => (
            <TableRow key={ex.invoiceId}>
              <TableCell className="font-medium">{ex.vendorName}</TableCell>
              <TableCell>{ex.invoiceNumber}</TableCell>
              <TableCell className="text-right tabular-nums">
                {new Intl.NumberFormat("en-US", {
                  style: "currency", currency: ex.currency,
                }).format(ex.grandTotal)}
              </TableCell>
              <TableCell className="max-w-xs truncate">{ex.reason}</TableCell>
              <TableCell>
                {ex.bankDetailsChanged ? (
                  <Badge variant="destructive">Bank changed</Badge>
                ) : ex.isNewVendor ? (
                  <Badge variant="secondary">New vendor</Badge>
                ) : (
                  <Badge variant="outline">Normal</Badge>
                )}
              </TableCell>
              <TableCell>
                <ResolveButtons invoiceId={ex.invoiceId} />
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  );
}

Server Action to Resume the Agent

tsx
// app/(dashboard)/exceptions/actions.ts
"use server";
 
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
 
export async function resolveException(
  invoiceId: string,
  action: "approve" | "reject",
  corrections: Record<string, unknown> = {}
) {
  const session = await auth();
  if (!session?.user?.permissions.includes("ap:resolve")) {
    throw new Error("Forbidden");
  }
 
  const res = await fetch(`${process.env.AP_API_URL}/exceptions/${invoiceId}/resolve`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      authorization: `Bearer ${session.accessToken}`,
    },
    body: JSON.stringify({ action, corrections }),
  });
 
  if (!res.ok) throw new Error(`Resolve failed: ${res.status}`);
  revalidatePath("/exceptions");
}
tsx
// app/(dashboard)/exceptions/resolve-buttons.tsx
"use client";
 
import { useTransition } from "react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { resolveException } from "./actions";
 
export function ResolveButtons({ invoiceId }: { invoiceId: string }) {
  const [pending, start] = useTransition();
 
  const handle = (action: "approve" | "reject") =>
    start(async () => {
      try {
        await resolveException(invoiceId, action);
        toast.success(action === "approve" ? "Approved" : "Rejected");
      } catch {
        toast.error("Could not resolve. Try again.");
      }
    });
 
  return (
    <div className="flex gap-2">
      <Button size="sm" disabled={pending} onClick={() => handle("approve")}>
        Approve
      </Button>
      <Button size="sm" variant="outline" disabled={pending} onClick={() => handle("reject")}>
        Reject
      </Button>
    </div>
  );
}

The API's /resolve endpoint enforces segregation of duties (the person who created the vendor cannot approve its first invoice), writes a user:ID event to the ledger, and calls the LangGraph service to resume the thread.

Shadcn UI exception dashboard mockup with invoice preview, highlighted variance fields and approve/reject buttons

Layer 6: B2B Disbursements with Stripe and Cashfree

Supporting two gateways is not over-engineering; it is market reality. Stripe gives you broad global coverage, especially for paying connected accounts and international vendors. Cashfree Payouts is a strong fit for Indian vendors, with bank transfer rails like IMPS, NEFT, RTGS, and UPI. Many SaaS customers have vendors in both worlds.

A Gateway-Agnostic Payout Interface

typescript
// api/src/payouts/types.ts
export interface PayoutRequest {
  invoiceId: string;
  tenantId: string;
  amountMinor: number;        // always integers in minor units
  currency: string;
  vendorRef: string;          // Stripe account ID or Cashfree beneficiary ID
  idempotencyKey: string;     // derived from invoiceId, never random
}
 
export interface PayoutProvider {
  name: "stripe" | "cashfree";
  send(req: PayoutRequest): Promise<{ providerRef: string; status: string }>;
}

Stripe Connect Transfer

typescript
// api/src/payouts/stripe.ts
import Stripe from "stripe";
import { PayoutProvider } from "./types";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export const stripeProvider: PayoutProvider = {
  name: "stripe",
  async send(req) {
    const transfer = await stripe.transfers.create(
      {
        amount: req.amountMinor,
        currency: req.currency.toLowerCase(),
        destination: req.vendorRef,
        transfer_group: `invoice_${req.invoiceId}`,
        metadata: { invoice_id: req.invoiceId, tenant_id: req.tenantId },
      },
      { idempotencyKey: req.idempotencyKey }
    );
    return { providerRef: transfer.id, status: "submitted" };
  },
};

Cashfree Payouts Transfer

typescript
// api/src/payouts/cashfree.ts
import { PayoutProvider } from "./types";
 
const BASE = process.env.CASHFREE_PAYOUT_BASE_URL!; // sandbox or production
 
export const cashfreeProvider: PayoutProvider = {
  name: "cashfree",
  async send(req) {
    const res = await fetch(`${BASE}/payout/transfers`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-client-id": process.env.CASHFREE_CLIENT_ID!,
        "x-client-secret": process.env.CASHFREE_CLIENT_SECRET!,
        "x-api-version": process.env.CASHFREE_API_VERSION!,
      },
      body: JSON.stringify({
        transfer_id: req.idempotencyKey,          // Cashfree dedupes on transfer_id
        transfer_amount: req.amountMinor / 100,
        transfer_currency: req.currency,
        transfer_mode: "imps",
        beneficiary_details: { beneficiary_id: req.vendorRef },
        transfer_remarks: `INV ${req.invoiceId.slice(0, 8)}`,
      }),
    });
    if (!res.ok) throw new Error(`Cashfree error ${res.status}: ${await res.text()}`);
    const data = await res.json();
    return { providerRef: data.cf_transfer_id ?? req.idempotencyKey, status: data.status };
  },
};

Payment APIs evolve. Always confirm endpoint paths, API version headers, and field names against the current Stripe and Cashfree documentation before going live, and test every flow in sandbox first.

The Payment Sequence

sequenceDiagram
    participant N as n8n Payment Run
    participant A as AP API
    participant DB as PostgreSQL
    participant G as Stripe / Cashfree

    N->>A: POST /payment-runs (approved invoice IDs)
    A->>DB: INSERT payment_intents (UNIQUE invoice_id)
    A->>DB: INSERT event PAYMENT_PENDING
    A->>G: Create transfer (idempotency key)
    G-->>A: 200 submitted
    G-->>A: Webhook: transfer succeeded
    A->>A: Verify webhook signature
    A->>DB: INSERT event PAID (provider ref)
    A->>N: Notify vendor remittance

The payment_intents table with a UNIQUE (invoice_id) constraint is your last line of defense against double payment. Even if n8n retries, even if two workers race, the database refuses the second intent. Combined with deterministic idempotency keys (for example, pay_ plus the invoice ID plus attempt number only after a confirmed failure), duplicate payouts become structurally impossible rather than merely unlikely.

Always treat the gateway webhook, not the API response, as the source of truth for PAID. A 200 response means "accepted," not "money arrived."

Layer 7: Docker Containerization and the AWS Blueprint

Local Development with Docker Compose

yaml
# docker-compose.yml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: ap
      POSTGRES_USER: ap_admin
      POSTGRES_PASSWORD: ${PG_PASSWORD}
    volumes: [pgdata:/var/lib/postgresql/data, ./db:/docker-entrypoint-initdb.d]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ap_admin -d ap"]
      interval: 5s
 
  ollama:
    image: ollama/ollama:latest
    volumes: [ollama:/root/.ollama]
    deploy:
      resources:
        reservations:
          devices: [{ driver: nvidia, count: 1, capabilities: [gpu] }]
 
  agents:
    build: ./agents
    environment:
      DATABASE_URL: postgresql://ap_admin:${PG_PASSWORD}@postgres:5432/ap
      OLLAMA_URL: http://ollama:11434
    depends_on: { postgres: { condition: service_healthy }, ollama: { condition: service_started } }
 
  ap-api:
    build: ./api
    ports: ["4000:4000"]
    environment:
      DATABASE_URL: postgresql://ap_app:${PG_APP_PASSWORD}@postgres:5432/ap
      N8N_ROUTING_WEBHOOK: http://n8n:5678/webhook/ap-routing
    depends_on: { postgres: { condition: service_healthy } }
 
  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    ports: ["5678:5678"]
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      N8N_ENCRYPTION_KEY: ${N8N_ENCRYPTION_KEY}
    volumes: [n8n:/home/node/.n8n]
 
  web:
    build: ./web
    ports: ["3000:3000"]
    environment:
      AP_API_URL: http://ap-api:4000
 
volumes: { pgdata: {}, ollama: {}, n8n: {} }

Pin image tags to specific versions in production; latest is fine on your laptop and dangerous everywhere else.

Production on AWS

flowchart TB
    U[Users] --> CF[CloudFront + WAF]
    CF --> ALB[Application Load Balancer]
    subgraph VPC[Private VPC]
        ALB --> WEB[ECS Fargate: Next.js]
        ALB --> API[ECS Fargate: AP API]
        API --> RDS[(RDS PostgreSQL Multi-AZ)]
        API --> AG[ECS Fargate: LangGraph Agents]
        AG --> GPU[EC2 GPU ASG: Ollama]
        N8N[ECS: n8n main + workers] --> RDS
        N8N --> API
        S3[(S3: Invoices, Object Lock)]
        AG --> S3
    end
    API --> SM[Secrets Manager]
    API --> EXT[Stripe / Cashfree]

Key decisions:

  • Fargate for stateless services (web, API, agents). No servers to patch.
  • GPU EC2 instances in an Auto Scaling Group for Ollama, since Fargate does not offer GPUs. Scale on queue depth, not CPU.
  • RDS PostgreSQL Multi-AZ with automated backups and point-in-time recovery. Your ledger deserves it.
  • n8n in queue mode with Redis (ElastiCache) and separate worker containers once volume grows.
  • S3 with Object Lock for original invoice files, giving you WORM retention for audits.
  • Secrets Manager for gateway keys, injected at runtime. Never bake secrets into images.
  • NAT egress allow-listing so only the API can reach payment gateways.

Real-World Example: A 400-Invoice-a-Day Distributor

To make this concrete, consider a scenario based on patterns I have seen: an industrial parts distributor processing around 400 vendor invoices a day across the US and India, with a three-person AP team.

Before automation, the team spent most of its time on data entry and chasing approvals. Average invoice cycle time was well over a week, and they routinely missed early-payment discounts.

After rolling out this architecture in phases:

  • Weeks 1–4: Extraction and validation only, with humans reviewing everything (shadow mode). This built a labeled dataset and calibrated confidence thresholds.
  • Weeks 5–8: Auto-approval enabled for matched invoices under a low threshold from established vendors.
  • Weeks 9–12: Thresholds raised, n8n discount-capture workflow switched on, Cashfree added for Indian vendors.

The outcome pattern you should aim for: the majority of invoices flowing straight through, the AP team shifting from data entry to vendor relationships and exception analysis, and captured early-payment discounts that alone can justify the SaaS subscription. Your mileage will vary with invoice quality and PO discipline, which is exactly why shadow mode matters.

Best Practices

  • Use decimals, never floats, for money. Decimal in Python, integer minor units or a decimal library in TypeScript, NUMERIC in PostgreSQL.
  • Start every tenant in shadow mode. Measure extraction accuracy on their real invoices before enabling auto-approval.
  • Version your prompts and models. Store the model name, prompt hash, and graph version in every ledger event so you can explain any historical decision.
  • Make thresholds per tenant and per vendor. A 1% variance tolerance is fine for office supplies and terrible for steel.
  • Enforce segregation of duties in code. Vendor creators cannot approve; approvers cannot initiate payment runs.
  • Treat webhooks as untrusted until verified. Validate signatures from Stripe, Cashfree, and your own services.
  • Observe the agents. Trace every LangGraph run (LangSmith or OpenTelemetry) and track touch rate, extraction accuracy, and exception reasons as product metrics.

Common Mistakes to Avoid

  1. Letting the LLM decide to pay. It will eventually be confidently wrong about something expensive.
  2. Mutable status columns. The first time an auditor asks "who changed this and when?", you will wish you had events.
  3. Random idempotency keys. If the key changes on retry, it is not idempotent. Derive it from the invoice.
  4. Trusting the payout API response as final. Bank transfers can fail or reverse after acceptance. Reconcile against webhooks and settlement reports.
  5. Auto-approving bank-detail changes. This is the classic business email compromise attack. Always require out-of-band verification.
  6. Overloading n8n with business logic. n8n should route and notify. Core validation and ledger writes belong in versioned, tested code.
  7. Skipping OCR quality checks. Garbage in, hallucination out. Detect blurry or rotated scans early and request a better copy.
  8. One giant "do everything" agent. Narrow nodes are easier to test, debug, and explain to auditors.

🚀 Pro Tips

  • Cache vendor layouts. After a few invoices from the same vendor, store a lightweight field-position hint and pass it into the prompt. Accuracy climbs and latency drops.
  • Use a smaller model first, escalate to a bigger one. Run a fast 7B-class model; if confidence is low or validation fails, retry with a larger model before bothering a human.
  • Sample auto-approved invoices. Route a small random percentage of straight-through invoices to the dashboard for QA. It catches silent drift.
  • Expose "exception reasons" as analytics. If 40% of exceptions are "No PO," the fix is a procurement policy, not a better model. This insight is a feature you can sell.
  • Build the remittance advice email in n8n. Vendors love knowing exactly which invoices a payment covers, and it cuts "where is my money?" emails dramatically.
  • Price on outcomes. Charging per invoice processed straight-through aligns your revenue with customer value and is an easy story for CFOs.
  • Run Ollama with keep-alive tuned. Keeping models warm avoids cold-load latency spikes during payment-run rushes.

📌 Key Takeaways

  • Agents propose, code disposes. LangGraph nodes extract and suggest; deterministic validation, policy engines, and database constraints make the financial decisions.
  • Privacy-first is a selling point. Local inference with Ollama keeps sensitive vendor and banking data inside your VPC, unlocking regulated enterprise buyers.
  • Your ledger is your moat. An append-only, hash-chained PostgreSQL event log makes the product auditable by design.
  • Human-on-the-Loop scales people, not headcount. LangGraph interrupts plus a focused Shadcn dashboard turn the AP team into supervisors of exceptions.
  • Money movement needs three locks. Duplicate detection, unique payment intents, and idempotency keys across Stripe and Cashfree.
  • Ship it in phases. Shadow mode, then low-threshold auto-approval, then full zero-touch, with metrics guiding every step.

Conclusion

Zero-touch AP is no longer a demo-day fantasy. The pieces have matured: LangGraph gives us durable, interruptible agent workflows; Ollama makes capable local models practical; n8n hands routing control back to the business; PostgreSQL remains the most trustworthy place to keep financial truth; and modern payout APIs from Stripe and Cashfree let us close the loop all the way to the vendor's bank account.

What separates a toy from an enterprise-grade fintech product is not the model you pick. It is the discipline around it: immutable records, deterministic checks, idempotent payments, and a respectful interface for the humans who still need to make judgment calls.

If you are a full-stack developer looking for a focused, high-value SaaS niche, this is a strong one. Every business has the problem, the ROI is measurable in days of cycle time and captured discounts, and the architecture above gives you a defensible foundation. Start with one customer, one vendor set, and shadow mode. Let the data tell you when to let go of the wheel.

Happy building, and may your ledgers always balance.

References

Frequently asked questions

What is the difference between Human-in-the-Loop and Human-on-the-Loop in AP automation?

Human-in-the-Loop means a person approves every invoice. Human-on-the-Loop means the system processes invoices autonomously and only escalates exceptions, such as price variances, new bank details, or low-confidence extractions, to a human. The goal is that people supervise the system instead of operating it.

Can local LLMs running on Ollama really extract invoice data accurately enough for finance?

Yes, for most structured and semi-structured invoices, provided you combine a capable vision or text model with OCR, constrain output to a JSON schema, and validate every field deterministically (totals, tax math, PO references). The LLM extracts; code verifies. Low-confidence results are routed to a human.

Why use n8n alongside LangGraph instead of doing everything in one framework?

LangGraph is excellent at stateful agent reasoning with checkpoints and interrupts. n8n is excellent at integrations, triggers, schedules, and business-editable routing rules like approval matrices and email notifications. Keeping them separate lets finance ops change routing in n8n without redeploying agent code.

How do you prevent duplicate vendor payments?

Use three layers: duplicate-invoice detection on vendor, invoice number, amount and date fingerprints; a unique payment intent per invoice enforced by a database constraint; and idempotency keys on every Stripe and Cashfree payout request so retries never create a second transfer.

Is this architecture suitable for a SOC 2 or audit-ready SaaS?

It is a strong foundation. The append-only ledger, hash chaining, role-based approvals, encrypted secrets, and private model inference map well to common audit controls. You still need formal policies, access reviews, and an auditor, but the technical evidence trail is built in.

Discussion

All Articles
AP AutomationLangGraphn8nPostgreSQLOllamaNext.jsFintechAI AgentsStripeCashfreeAWSDocker

Written by

Niraj Kumar

Software Developer — building scalable systems for businesses.

Building this for real? LangChain Developer Services — Production LangChain & LangGraph systems for RAG, agents, and AI workflows.