The Hidden Cost of Accounts Payable: Why Invoice Processing Is the Last Manual Process Standing in Enterprise Finance
Most enterprise finance teams have automated their general ledger, their payroll, their expense reporting, and their financial close. And then there is accounts payable — still largely manual, still dependent on data entry, still running on a combination of email attachments, shared drives, and approval chains that require human attention at every step.
The reason AP automation has lagged is not that the problem is hard to understand. It is that the problem is hard to solve completely. OCR tools can extract text from invoices. Workflow tools can route approvals. ERP systems can post journal entries. But connecting those three capabilities into a system that handles the full workflow — from a PDF invoice arriving in an inbox to a validated, matched, and approved payment entry in the ERP — has traditionally required either expensive enterprise software or significant custom integration work.
Generative AI changes that equation. Not because LLMs can replace any of those individual capabilities, but because they can orchestrate them. An agent that can invoke document extraction, perform three-way matching logic, query a vendor database, evaluate exceptions, and generate an approval recommendation — and do so with enough contextual reasoning to handle the edge cases that rule-based automation gets wrong — is a qualitatively different capability from any individual automation tool.
When we engaged with a mid-market manufacturing enterprise whose AP team was processing approximately 1,200 invoices per month with a team of four, the problem was familiar and measurable: average processing time of 4.2 days per invoice, a 12% exception rate that required manual investigation, duplicate payment incidents averaging two per quarter, and an AP team spending 70% of their time on data entry and routing rather than financial control and vendor relationship work.
This post documents the autonomous AP agent we built on Amazon Bedrock Agents — combining Amazon Textract for document intelligence, DynamoDB for vendor master lookup, Lambda-based three-way match validation, and human escalation via Amazon SNS — to process invoices end-to-end with human involvement limited to genuine exceptions.
As Technical Architect for this initiative at AeonX Digital, I designed the agent architecture, defined the tool contracts, and led the implementation across our AI and finance technology teams. What follows covers the decisions that shaped the agent design, the implementation in enough detail to be replicable, and the hard lessons that only emerge in production.
The outcome: average invoice processing time reduced from 4.2 days to 6.8 hours, exception rate handled autonomously increased from 0% to 74%, and the AP team redirected from data entry to financial control and vendor performance management.
Why This Matters Now: The Agentic AI Moment in Finance Operations
Three shifts are converging to make autonomous AP processing viable at enterprise scale today — not in three years:
- Foundation models now reason over structured and unstructured data simultaneously — The critical gap in previous AP automation was the inability to connect document extraction outputs to business logic. A rule engine cannot decide that a 2% unit price variance on a vendor with a strong payment history and an approved contract amendment is acceptable, but the same variance from a new vendor with no PO coverage requires escalation. That reasoning requires contextual judgment — which foundation models now provide reliably enough for operational use.
- The tool-use paradigm makes agents genuinely composable — Amazon Bedrock Agents' tool-use architecture means the agent can invoke any Lambda function as a capability. Document extraction, vendor lookup, ERP write, approval notification — each becomes a tool the agent orchestrates. Adding a new capability means adding a new Lambda function and registering it as a tool. The agent's reasoning layer does not change.
- Confidence-based escalation solves the automation trust problem — The reason CFOs have historically been reluctant to automate AP decisions is the fear of what the system does when it is wrong. Confidence-based escalation — where the agent processes what it can handle confidently and routes genuine uncertainty to a human with a pre-filled investigation context — addresses that concern directly. Automation handles the routine. Humans handle the exceptions. The boundary is explicit and auditable.
The decision to build this agent was driven by a finance director who had watched AP automation fail twice before — once with an OCR tool that required too much manual correction and once with a workflow tool that automated the routing but not the thinking. She was willing to try again because this architecture addressed both failure modes.
The Business Problem
The AP team's manual workflow had:
- Invoice receipt via email attachments and a supplier portal — no structured ingestion
- Manual data entry from PDFs into SAP: vendor code, invoice number, line items, tax amounts
- Manual PO lookup and three-way match (invoice vs. PO vs. goods receipt) performed in spreadsheets
- Approval routing via email chains — no audit trail, no SLA enforcement
- Duplicate detection performed manually by experienced AP staff — two duplicate payments per quarter on average
- ERP journal entry posted manually after approval
Business impact:
- 4.2-day average processing time meant vendors frequently chased payment status, consuming AP team time
- Early payment discount capture rate of 18% — most discounts expired before approval was complete
- Two duplicate payments per quarter averaging ₹3.4 lakh per incident in recovery costs and vendor relationship damage
- 70% of AP team time on data entry and routing versus 30% on financial control — an inverted skill utilization ratio
- No structured data on invoice aging, vendor performance, or exception patterns — making process improvement impossible
The goal was not to eliminate the AP team. It was to eliminate the work that did not require their expertise — data entry, routine matching, status checking — and redirect their time toward the work that did: exception investigation, vendor negotiation, and payment strategy.
Technical Architecture

Figure 1: Autonomous Accounts Payable Agent — Hub-and-Spoke Tool Orchestration on Amazon Bedrock Agents
AWS and Technology Stack:
- Amazon Bedrock Agents (Claude 3 Sonnet) — agent orchestration, reasoning, and tool invocation
- Amazon Textract — multi-page PDF invoice extraction with form and table analysis
- Amazon S3 — invoice document storage and processed output staging
- Amazon DynamoDB — vendor master data, PO repository, goods receipt records, processing audit log
- AWS Lambda — four agent tools: invoice extraction, vendor lookup, three-way match, ERP write
- AWS Step Functions — human escalation workflow with timeout and reminder handling
- Amazon SNS — AP team notifications for escalation events and daily processing summaries
- Amazon SES — structured escalation emails to AP reviewers with pre-filled investigation context
- Amazon EventBridge — invoice ingestion trigger from S3 upload events
- AWS CloudTrail and Amazon CloudWatch — audit logging and processing metrics
- AWS Secrets Manager — SAP ERP API credentials
The architecture follows a hub-and-spoke model: the Bedrock Agent is the hub, invoking tools (the spokes) as needed to progress an invoice through the workflow. The agent maintains context across tool calls within a single session — it knows what it has extracted, what it has validated, and what decisions it has made before deciding what to do next.
Key Architectural Decisions
These are the decisions that shaped the agent — and the reasoning behind each one.
Decision 1: Why Bedrock Agents Instead of a Chained Lambda Workflow?
When we began designing the AP automation, the first proposal from the engineering team was a deterministic Lambda chain: Step 1 extracts, Step 2 validates, Step 3 matches, Step 4 routes. Simple, predictable, easy to test.
The problem with a deterministic chain is that AP processing is not a deterministic problem. Invoices arrive with missing fields. Vendor names on invoices do not always match the vendor master exactly. Unit prices drift by fractions of a percent due to currency rounding. PO line items get partially fulfilled across multiple invoices. A rigid chain handles the clean 88% of invoices and fails on the messy 12% — sending them to a human with no context about why the automation stopped.
Bedrock Agents handle the messy 12% differently. The agent does not fail when a vendor name doesn't exactly match — it reasons about whether "Acme Pvt Ltd" and "Acme Private Limited" are the same entity given the tax registration number match, and makes a confident recommendation. It does not stop when a price variance exists — it checks whether the variance is within the approved tolerance for that vendor tier and documents its reasoning. The agent produces an outcome with a stated confidence level and a documented rationale, which is far more useful than a silent failure from a rule that wasn't written to handle the edge case.
| Approach | Handles Clean Invoices | Handles Edge Cases | Audit Trail | Escalation Quality |
|---|---|---|---|---|
| Deterministic Lambda chain | Well | Fails — sends to human with no context | Good | Poor — human gets raw data |
| Rule-based decision engine | Well | Partially — only cases the rules cover | Good | Moderate — human gets rule failure |
| Bedrock Agent with tools | Well | Well — reasons over edge cases | Excellent | Excellent — human gets agent reasoning |
The business decision: The AP team's escalation workload under the old system was not random — it was concentrated in the 12% of invoices with ambiguities that a rule engine could not resolve. The agent handles that 12% the way an experienced AP analyst would: by reasoning through the available evidence and making a documented recommendation, even when the answer is "I'm not confident enough — here's what I found and here's what needs human review."
Decision 2: Why Textract Instead of Bedrock Document Analysis for Extraction?
Amazon Bedrock can analyze document images directly — you can pass a PDF page as a base64-encoded image and ask the model to extract structured data. We evaluated this approach before choosing Textract. The decision came down to three factors:
- Consistency at scale — Textract's form and table extraction produces structured key-value pairs and table cells with bounding box coordinates and confidence scores per field. Bedrock's document analysis produces free-text responses that require additional parsing. At 1,200 invoices per month, the consistency and parsability of Textract output is operationally more reliable.
- Cost — Textract's AnalyzeDocument API costs $0.015 per page for form and table analysis. Running a multi-page invoice through Bedrock as images costs significantly more per page at typical invoice complexity. For a high-volume extraction workload, Textract is the right tool.
- Confidence scores per field — Textract returns a confidence score for each extracted field. The agent uses these scores to decide whether to trust an extracted value or flag it for human verification. Bedrock document analysis does not produce per-field confidence scores.
The business decision: Textract does one thing — document extraction — and does it reliably and cheaply at scale. Bedrock reasons about what was extracted. Using each service for what it does best produces a more reliable and cost-efficient pipeline than asking Bedrock to do both.
Decision 3: Why Confidence-Based Escalation Instead of Exception-Based Escalation?
Traditional AP automation escalates on exceptions — when a rule fails, send to human. The problem is that rule failures produce poor escalation quality: the human receives a notification that "three-way match failed" with no context about why, what the variance is, whether it's likely benign, or what information they need to resolve it.
We designed the agent around confidence-based escalation: the agent always produces a recommendation, even when uncertain. When confidence is below the threshold (set at 0.78 after calibration on three months of historical invoices), the agent escalates — but it escalates with its full reasoning, the specific uncertainty that prevented higher confidence, the data it found, and a suggested resolution path.
The difference in practice: instead of "three-way match failed on Invoice INV-2024-8841", the AP reviewer receives "Invoice INV-2024-8841 from Vendor Mehta Logistics: unit price variance of ₹14.20/MT (1.8%) against PO-2024-1123. Vendor has an approved ±2% tolerance on contracted rates. However, the goods receipt for this PO shows partial delivery (80 MT of 100 MT ordered), and the invoice claims full quantity. Recommend verifying with warehouse team before approval. Confidence: 0.61."
That second message takes the AP reviewer from an investigation starting point of zero to an investigation starting point of 80%. It is the difference between a 45-minute exception resolution and a 12-minute one.
Implementation Pattern
Agent Tool Definitions
The agent has four tools registered in Amazon Bedrock Agents. Each tool is a Lambda function with a defined input schema. The agent decides when to call each tool, in what order, and with what parameters — based on its reasoning about the current invoice state.
# Tool definitions registered in Bedrock Agents console / via API
# Each tool_spec maps to a Lambda function ARN
TOOL_SPECS = [
{
"name": "extract_invoice_data",
"description": (
"Extract structured data from a PDF invoice stored in S3. "
"Returns vendor name, invoice number, invoice date, line items "
"(description, quantity, unit price, amount), tax details, "
"total amount, and per-field confidence scores. "
"Call this first for every new invoice."
),
"inputSchema": {
"type": "object",
"properties": {
"s3_bucket": {"type": "string", "description": "S3 bucket containing the invoice PDF"},
"s3_key": {"type": "string", "description": "S3 object key of the invoice PDF"}
},
"required": ["s3_bucket", "s3_key"]
}
},
{
"name": "lookup_vendor",
"description": (
"Look up a vendor in the master data registry using name, "
"tax registration number (GSTIN), or vendor code. "
"Returns vendor ID, approved payment terms, price tolerance tier, "
"payment history score (0-1), and active contract details if any. "
"Use this to validate the invoice vendor against the approved vendor list."
),
"inputSchema": {
"type": "object",
"properties": {
"vendor_name": {"type": "string"},
"gstin": {"type": "string", "description": "GST registration number if available"},
"vendor_code": {"type": "string", "description": "ERP vendor code if available on invoice"}
}
}
},
{
"name": "validate_three_way_match",
"description": (
"Perform three-way match: invoice vs. purchase order vs. goods receipt. "
"Returns match status (MATCHED, PARTIAL_MATCH, MISMATCH), "
"variance details per line item, whether variances are within "
"the vendor's approved tolerance, and a recommended action. "
"Requires vendor_id and invoice line items from prior tool calls."
),
"inputSchema": {
"type": "object",
"properties": {
"vendor_id": {"type": "string"},
"invoice_number": {"type": "string"},
"invoice_lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "number"},
"unit_price": {"type": "number"},
"amount": {"type": "number"}
}
}
},
"invoice_total": {"type": "number"},
"invoice_date": {"type": "string", "description": "ISO 8601 date"}
},
"required": ["vendor_id", "invoice_number", "invoice_lines", "invoice_total"]
}
},
{
"name": "post_to_erp",
"description": (
"Post an approved invoice as a journal entry to the SAP ERP system. "
"Only call this when confidence is >= 0.78 AND three-way match "
"status is MATCHED or PARTIAL_MATCH within tolerance. "
"Returns ERP document number and posting confirmation."
),
"inputSchema": {
"type": "object",
"properties": {
"vendor_id": {"type": "string"},
"invoice_number": {"type": "string"},
"invoice_date": {"type": "string"},
"invoice_total": {"type": "number"},
"line_items": {"type": "array"},
"payment_terms": {"type": "string"},
"po_reference": {"type": "string"}
},
"required": ["vendor_id", "invoice_number", "invoice_total", "line_items"]
}
}
]
Invoice Extraction Tool: Textract Integration
import boto3
import json
textract = boto3.client("textract", region_name="ap-south-1")
s3 = boto3.client("s3", region_name="ap-south-1")
def extract_invoice_data(event, context):
"""
Tool handler: extract structured invoice data using Textract.
Called by Bedrock Agent via Lambda invoke.
"""
params = event.get("parameters", [])
param_map = {p["name"]: p["value"] for p in params}
bucket = param_map["s3_bucket"]
key = param_map["s3_key"]
# Use AnalyzeDocument for form fields + table extraction
# FORMS extracts key-value pairs (vendor name, invoice number, dates)
# TABLES extracts line item grids
response = textract.analyze_document(
Document={"S3Object": {"Bucket": bucket, "Name": key}},
FeatureTypes=["FORMS", "TABLES"]
)
# Parse key-value pairs (header fields)
fields = {}
key_map, value_map, block_map = {}, {}, {}
for block in response["Blocks"]:
block_map[block["Id"]] = block
if block["BlockType"] == "KEY_VALUE_SET":
if "KEY" in block.get("EntityTypes", []):
key_map[block["Id"]] = block
else:
value_map[block["Id"]] = block
for key_id, key_block in key_map.items():
key_text = _get_text(key_block, block_map)
value_text = ""
confidence = key_block.get("Confidence", 0) / 100
for rel in key_block.get("Relationships", []):
if rel["Type"] == "VALUE":
for val_id in rel["Ids"]:
if val_id in value_map:
value_text = _get_text(value_map[val_id], block_map)
confidence = min(confidence,
value_map[val_id].get("Confidence", 0) / 100)
if key_text:
fields[key_text.strip().lower()] = {
"value": value_text.strip(),
"confidence": round(confidence, 3)
}
# Parse line items from TABLE blocks
line_items = _extract_line_items(response["Blocks"], block_map)
return {
"statusCode": 200,
"body": json.dumps({
"fields": fields,
"line_items": line_items,
"page_count": len([b for b in response["Blocks"]
if b["BlockType"] == "PAGE"])
})
}
def _get_text(block: dict, block_map: dict) -> str:
text = ""
for rel in block.get("Relationships", []):
if rel["Type"] == "CHILD":
for child_id in rel["Ids"]:
child = block_map.get(child_id, {})
if child.get("BlockType") == "WORD":
text += child.get("Text", "") + " "
return text.strip()
def _extract_line_items(blocks: list, block_map: dict) -> list:
"""Extract table rows as invoice line items."""
tables, line_items = [], []
for block in blocks:
if block["BlockType"] == "TABLE":
table = {}
for rel in block.get("Relationships", []):
if rel["Type"] == "CHILD":
for cell_id in rel["Ids"]:
cell = block_map.get(cell_id, {})
if cell.get("BlockType") == "CELL":
row = cell["RowIndex"]
col = cell["ColumnIndex"]
table.setdefault(row, {})[col] = {
"text": _get_text(cell, block_map),
"confidence": round(cell.get("Confidence", 0) / 100, 3)
}
tables.append(table)
for table in tables:
if not table:
continue
# Row 1 is assumed to be the header row
headers = {col: cell["text"].lower()
for col, cell in table.get(1, {}).items()}
for row_idx in range(2, max(table.keys()) + 1):
row = table.get(row_idx, {})
if row:
line_items.append({
headers.get(col, f"col_{col}"): cell
for col, cell in row.items()
})
return line_items
Three-Way Match Validation Tool
import boto3
from decimal import Decimal, ROUND_HALF_UP
from datetime import datetime
dynamodb = boto3.resource("dynamodb", region_name="ap-south-1")
po_table = dynamodb.Table("PurchaseOrders")
gr_table = dynamodb.Table("GoodsReceipts")
inv_table = dynamodb.Table("ProcessedInvoices")
def validate_three_way_match(event, context):
params = event.get("parameters", [])
p = {item["name"]: item["value"] for item in params}
vendor_id = p["vendor_id"]
invoice_number = p["invoice_number"]
invoice_lines = json.loads(p["invoice_lines"])
invoice_total = Decimal(str(p["invoice_total"]))
invoice_date = p.get("invoice_date", datetime.utcnow().date().isoformat())
# ── 1. Duplicate check ───────────────────────────────────────────────
existing = inv_table.get_item(
Key={"vendor_id": vendor_id, "invoice_number": invoice_number}
).get("Item")
if existing:
return _result("DUPLICATE", 0.99,
f"Invoice {invoice_number} from vendor {vendor_id} was already "
f"processed on {existing['processed_date']}. "
f"ERP document: {existing.get('erp_doc_number', 'N/A')}. "
f"Do NOT post. Flag for AP review.")
# ── 2. Find matching open PO ─────────────────────────────────────────
po_response = po_table.query(
IndexName="VendorStatusIndex",
KeyConditionExpression="vendor_id = :v AND #st = :s",
FilterExpression="invoice_date_window_end >= :d",
ExpressionAttributeNames={"#st": "status"},
ExpressionAttributeValues={
":v": vendor_id,
":s": "OPEN",
":d": invoice_date
}
)
open_pos = po_response.get("Items", [])
if not open_pos:
return _result("NO_PO", 0.92,
f"No open purchase orders found for vendor {vendor_id}. "
f"Invoice cannot be matched. Escalate to AP team with PO inquiry.")
# ── 3. Match invoice lines to best-fit PO ────────────────────────────
best_po, best_score = None, 0.0
for po in open_pos:
score = _compute_match_score(invoice_lines, po["line_items"])
if score > best_score:
best_score, best_po = score, po
if best_score < 0.50:
return _result("MISMATCH", 0.85,
f"Best PO match score is {best_score:.0%} — insufficient for auto-match. "
f"Closest PO: {best_po['po_number']} (raised {best_po['po_date']}). "
f"Manual line-item review required.")
# ── 4. Get goods receipt for matched PO ──────────────────────────────
gr_response = gr_table.query(
IndexName="POIndex",
KeyConditionExpression="po_number = :p",
ExpressionAttributeValues={":p": best_po["po_number"]}
)
receipts = gr_response.get("Items", [])
gr_qty_received = sum(
Decimal(str(r.get("quantity_received", 0))) for r in receipts
)
po_qty_ordered = sum(
Decimal(str(l.get("quantity", 0))) for l in best_po["line_items"]
)
invoice_qty = sum(
Decimal(str(l.get("quantity", {}).get("value", 0)
if isinstance(l.get("quantity"), dict)
else l.get("quantity", 0)))
for l in invoice_lines
)
# ── 5. Price variance check ──────────────────────────────────────────
price_variance = abs(invoice_total - best_po["po_total"]) / best_po["po_total"]
tolerance = Decimal(str(best_po.get("price_tolerance_pct", 0.02))) # Default 2%
qty_match = abs(invoice_qty - gr_qty_received) / max(gr_qty_received, Decimal("1")) < Decimal("0.01")
price_ok = Decimal(str(price_variance)) <= tolerance
if qty_match and price_ok:
confidence = 0.94 - float(price_variance) * 2 # Slight reduction for any variance
return _result("MATCHED", round(confidence, 2),
f"Three-way match successful. PO: {best_po['po_number']}, "
f"GR quantity matches, price variance {price_variance:.2%} "
f"within {float(tolerance):.0%} tolerance.",
po_number=best_po["po_number"])
issues = []
if not qty_match:
issues.append(
f"Quantity mismatch: invoice claims {float(invoice_qty):.1f} units, "
f"GR confirms {float(gr_qty_received):.1f} units received"
)
if not price_ok:
issues.append(
f"Price variance {price_variance:.2%} exceeds {float(tolerance):.0%} "
f"tolerance for this vendor tier"
)
confidence = 0.55 if len(issues) == 2 else 0.65
return _result("PARTIAL_MATCH", confidence,
f"Match issues found: {'; '.join(issues)}. PO: {best_po['po_number']}. "
f"Human review required before approval.",
po_number=best_po["po_number"])
def _result(status, confidence, message, po_number=None):
body = {"match_status": status, "confidence": confidence, "message": message}
if po_number:
body["po_number"] = po_number
return {"statusCode": 200, "body": json.dumps(body)}
def _compute_match_score(invoice_lines, po_lines):
"""Simple description-overlap score to find the best-matching PO."""
if not invoice_lines or not po_lines:
return 0.0
matched = 0
for inv_line in invoice_lines:
inv_desc = str(inv_line.get("description", {}).get("text", "")
if isinstance(inv_line.get("description"), dict)
else inv_line.get("description", "")).lower()
for po_line in po_lines:
po_desc = str(po_line.get("description", "")).lower()
words = set(inv_desc.split()) & set(po_desc.split())
if len(words) >= 2:
matched += 1
break
return matched / max(len(invoice_lines), 1)
### Agent Invocation: Orchestration Entry Point
The agent is triggered by an EventBridge rule that fires when a new invoice PDF is uploaded to the designated S3 prefix. A Lambda function initializes the Bedrock Agent session and passes the S3 location as the opening message. The agent handles everything from there — deciding which tools to call, in what order, and when to stop.
```python
import boto3
import json
import uuid
bedrock_agent = boto3.client("bedrock-agent-runtime", region_name="ap-south-1")
sns = boto3.client("sns", region_name="ap-south-1")
dynamodb = boto3.resource("dynamodb", region_name="ap-south-1")
audit_table = dynamodb.Table("APProcessingAudit")
AGENT_ID = "ABCDE12345" # From Bedrock Agents console
AGENT_ALIAS = "PROD"
ESCALATION_TOPIC = "arn:aws:sns:ap-south-1:111122223333:ap-escalations"
CONFIDENCE_THRESHOLD = 0.78
def process_invoice(event, context):
"""
Triggered by EventBridge when an invoice PDF lands in S3.
Initializes Bedrock Agent session and monitors for completion.
"""
detail = event.get("detail", {})
s3_bucket = detail.get("bucket", {}).get("name")
s3_key = detail.get("object", {}).get("key")
session_id = str(uuid.uuid4())
opening_message = (
f"Process the invoice PDF stored at s3://{s3_bucket}/{s3_key}. "
f"Extract all invoice data, look up the vendor, perform three-way match "
f"validation, and post to ERP if confidence is {CONFIDENCE_THRESHOLD} or above. "
f"If confidence is below {CONFIDENCE_THRESHOLD}, prepare a detailed escalation "
f"summary explaining exactly what you found and what needs human review. "
f"Document your reasoning at each step."
)
# Invoke agent — synchronous for invoices under ~30s processing time
response = bedrock_agent.invoke_agent(
agentId=AGENT_ID,
agentAliasId=AGENT_ALIAS,
sessionId=session_id,
inputText=opening_message,
enableTrace=True # Capture full reasoning trace for audit
)
# Stream and collect agent response
full_response = ""
trace_events = []
for event_chunk in response.get("completion", []):
if "chunk" in event_chunk:
full_response += event_chunk["chunk"].get("bytes", b"").decode("utf-8")
if "trace" in event_chunk:
trace_events.append(event_chunk["trace"])
# Parse the agent's final output to determine disposition
result = _parse_agent_result(full_response)
# Write processing record
audit_table.put_item(Item={
"session_id": session_id,
"s3_key": s3_key,
"disposition": result["disposition"], # AUTO_POSTED | ESCALATED | DUPLICATE
"confidence": str(result.get("confidence", 0)),
"erp_doc": result.get("erp_doc_number", ""),
"agent_summary": full_response[:2000], # Truncated for storage
"trace_count": len(trace_events),
"timestamp": __import__("datetime").datetime.utcnow().isoformat(),
"ttl": int(__import__("time").time()) + (365 * 86400)
})
# Escalate if agent was not confident enough to auto-post
if result["disposition"] == "ESCALATED":
_send_escalation(s3_key, result, session_id)
return {"statusCode": 200, "body": json.dumps(result)}
def _send_escalation(s3_key: str, result: dict, session_id: str):
"""Send structured escalation to AP team via SNS."""
sns.publish(
TopicArn=ESCALATION_TOPIC,
Subject=f"AP Review Required: {s3_key.split('/')[-1]}",
Message=json.dumps({
"invoice_file": s3_key,
"session_id": session_id,
"confidence": result.get("confidence"),
"agent_finding": result.get("escalation_reason"),
"suggested_action": result.get("suggested_action"),
"review_url": f"https://ap-portal.internal/review/{session_id}"
}),
MessageAttributes={
"escalation_type": {
"DataType": "String",
"StringValue": result.get("escalation_type", "REVIEW_REQUIRED")
}
}
)
def _parse_agent_result(response_text: str) -> dict:
"""
Extract structured disposition from agent's final response.
Agent is prompted to end with a JSON summary block.
"""
import re
json_match = re.search(r'\{[\s\S]*"disposition"[\s\S]*\}', response_text)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
pass
# Fallback — treat as escalation if we cannot parse a clean result
return {
"disposition": "ESCALATED",
"confidence": 0.0,
"escalation_reason": "Agent response could not be parsed — manual review required",
"escalation_type": "PARSE_ERROR"
}
Human Escalation Workflow: Step Functions
For invoices the agent escalates, a Step Functions workflow manages the human review lifecycle — sending the initial notification, tracking the SLA, sending reminders, and escalating to the AP manager if the reviewer does not respond within the defined window.
EventBridge (invoice uploaded)
→ Lambda: process_invoice (Bedrock Agent session)
→ Agent disposition = ESCALATED
→ Step Functions: APEscalationWorkflow
→ State 1: SendEscalationEmail (SES — pre-filled review form)
→ State 2: Wait 4 hours (business hours SLA)
→ State 3 (parallel):
Check reviewed?
YES → Record decision, trigger ERP post if approved
NO → Send reminder to reviewer
→ State 4: Wait additional 4 hours
→ State 5 (parallel):
Still not reviewed?
YES → Escalate to AP Manager + flag in dashboard
NO → Record decision
→ State 6: UpdateAuditRecord (DynamoDB)
Average human review time for escalated invoices dropped from 45 minutes to 12 minutes once reviewers started receiving the agent's pre-filled investigation context instead of raw invoice data. The agent does not just flag a problem — it explains what it found, what the specific uncertainty is, and what the reviewer needs to verify.
Cost Architecture and AWS Infrastructure Spend
At steady-state processing approximately 1,200 invoices per month, with 74% auto-posted and 26% escalated to human review:
| Service | Usage | Estimated Monthly Cost |
|---|---|---|
| Amazon Bedrock (Claude 3 Sonnet) | ~1,200 agent sessions, avg 3,200 input + 800 output tokens per session (multi-turn) | ~$25 |
| Amazon Textract | 1,200 invoices × avg 3.2 pages × $0.015/page (AnalyzeDocument) | ~$58 |
| AWS Lambda | ~18K invocations (agent tools + triggers), well within free tier | ~$0 |
| Amazon DynamoDB | ~15K writes/month across audit + PO + GR tables, on-demand | ~$1 |
| Amazon S3 | ~12 GB/month invoice storage + processed outputs | ~$1 |
| AWS Step Functions | ~312 escalation workflow executions × 6 states | ~$1 |
| Amazon SNS + SES | ~312 escalation alerts + reminders | ~$1 |
| Amazon CloudWatch | Metrics, alarms, dashboard | ~$8 |
| AWS Secrets Manager | SAP ERP API credentials | ~$1 |
| Total | ~$96/month |
Common Pitfalls (Real Lessons)
| Pitfall | What Happened | How We Fixed It |
|---|---|---|
| Agent called post_to_erp on a duplicate invoice | Bedrock Agent invoked the ERP post tool before the three-way match tool returned the duplicate flag | Reordered tool description priority: duplicate check is now the first explicit instruction in the agent's system prompt |
| Textract extracted table headers as line items | Row 1 (header) was included in invoice line items, causing spurious unit price entries of "Unit Price" as a quantity | Added header row detection in _extract_line_items — skips rows where numeric fields contain non-numeric text |
| Agent reasoning loop on ambiguous vendor names | Agent called lookup_vendor six times with slight variations on a vendor name, exhausting the session token budget | Added a max_attempts parameter to the vendor lookup tool; after 2 failed lookups, the tool returns a "vendor not found — escalate" response |
| Step Functions escalation SLA crossed midnight | A 4-hour SLA window starting at 10pm crossed midnight and the business-hours check treated it as same-day | Changed SLA calculation to business-hours only using a utility function; overnight invoices SLA starts at 9am next business day |
| Confidence score inconsistency across tool outputs | Three-way match returned 0.94 confidence but the agent's overall confidence was 0.61, causing unnecessary escalations | Clarified in the agent system prompt that overall confidence is not the minimum across tools — it is the agent's holistic assessment including all available evidence |
The duplicate invoice pitfall was the most consequential. In early testing, the agent posted a duplicate invoice to the ERP because it called post_to_erp before completing the duplicate check tool call in one session where the tool response was slow. The fix — making the duplicate check the first explicit instruction in the system prompt and adding a guard in the ERP post tool itself — illustrates an important principle: agent tool ordering cannot rely on the agent always choosing the optimal sequence. Critical safety checks must be enforced both in the agent instructions and in the tool implementation.
Business Outcomes
| Metric | Before Agent | After Agent | Business Impact |
|---|---|---|---|
| Average invoice processing time | 4.2 days | 6.8 hours | Vendors paid faster; early discount capture improved |
| Early payment discount capture | 18% | 61% | ₹7.7 lakh/year on ₹1 crore monthly AP volume at 1.5% avg discount terms |
| Invoices auto-processed (no human touch) | 0% | 74% | AP team time freed from routine processing |
| Duplicate payment incidents | ~2/quarter | 0 since launch | ₹6.8 lakh/quarter in prevented recovery costs |
| Human review time per exception | 45 minutes | 12 minutes | Agent pre-fills investigation context |
| AP team time on data entry | ~70% | ~18% | Redirected to financial control and vendor management |
| Invoice processing audit trail | Manual, incomplete | Full session trace per invoice | Audit-ready with agent reasoning documented |
The early payment discount capture improvement was the unexpected highlight. At 18% capture rate under the manual process, the team was losing discounts primarily because invoices sat in approval queues past the discount window. At 74% auto-processing with same-day posting, the discount window is now routinely met for straightforward invoices. On approximately ₹1 crore in monthly AP volume with an average early payment discount of 1.5%, the improvement from 18% to 61% capture translates to approximately ₹7.7 lakh in annual discount recovery — against a platform cost of approximately ₹96,000/year.
Lessons for Technology Leaders
- Agents are not replacements for deterministic logic — they are the connective tissue between deterministic tools — The three-way match logic, the duplicate check, the ERP post are all deterministic. The agent's value is in orchestrating those tools intelligently, handling the edge cases that deterministic chains cannot anticipate, and producing documented reasoning when it is uncertain. Build reliable tools first. Let the agent orchestrate them.
- Confidence-based escalation produces better outcomes than exception-based escalation — When a rule engine fails, it sends a human a notification that something went wrong. When an agent escalates, it sends a human a structured briefing on what it found, what it could not resolve, and what the reviewer needs to check. The 12-minute average review time versus the previous 45 minutes comes entirely from escalation quality, not reviewer skill.
- Critical safety checks must be enforced in both the agent instructions and the tool implementation — The agent cannot be relied upon to always call tools in the correct order, especially under token pressure or unusual input. The duplicate check exists in the agent's system prompt as the first instruction and in the ERP post tool as an independent guard. Defense in depth applies to agents as much as it does to security architecture.
- Start with the highest-confidence invoice class first — We launched with invoices from the top 20 vendors by volume: well-known vendors, clean data, established PO patterns. That subset had a 91% auto-processing rate and gave the AP team confidence in the system before it encountered messier invoices. Phased onboarding by invoice complexity is the right go-live strategy for any AP automation.
- The ROI calculation for AP automation includes recovered discounts, not just labor cost — Most business cases for AP automation focus on FTE efficiency. The discount capture improvement — from 18% to 61% on approximately ₹1 crore monthly AP volume — produced approximately ₹7.7 lakh in annual savings at 1.5% average discount terms. Combined with duplicate payment prevention (₹6.8 lakh/quarter recovered), the financial case is significantly stronger than labor cost alone. Include both in your business case.
About the Author
Chandni Gadhvi is Program Manager – Data and AI at AeonX Digital Technology Limited, where she leads the architecture and delivery of cloud-native AI solutions for enterprise operations. She specializes in building intelligent, event-driven systems on AWS that convert operational data into business decisions. She is an advocate for data-first AI strategy and shares technical thought leadership to help engineering leaders move from pilot to production on AWS.
