# 12% of Our Invoices Had Errors. Now an AI Agent Catches Them in Seconds.
> A 60-person marketing agency built a Struere agent that validates incoming invoices against purchase orders in Airtable, flags discrepancies, categorizes expenses, and notifies approvers on WhatsApp — cutting invoice processing time by 80%.

Published: 2026-04-01
Tags: automation, agents, accounting, case-study, airtable


# 200 Invoices a Month, and Nobody Trusts the Numbers

Lin runs finance at Bright Arc, a 60-person marketing agency with 40 active client accounts and a vendor list that changes every quarter. Media buys, freelance designers, printing houses, event venues, software subscriptions — the agency spends $380K/month across 70+ vendors. Every dollar passes through Lin's inbox as a PDF invoice.

The workflow: a vendor invoice arrives by email. Lin opens it, reads the line items, opens the Airtable PO database, searches for the matching purchase order, compares amounts line by line, checks the tax calculation, categorizes each expense, and forwards the invoice to the right budget owner for approval. That takes 8-12 minutes when everything matches. When something does not match — and 12% of the time it does not — Lin opens a new email to the vendor, explains the discrepancy, waits for a corrected invoice, and starts again. Each discrepancy takes 45 minutes to resolve on average. With 24 mismatched invoices per month, that is 18 hours of back-and-forth.

Last quarter, a print vendor invoiced Bright Arc for 5,000 brochures at $0.42 each. The PO said $0.38. The difference — $200 — was small enough that Lin's associate approved it without checking. It happened three more times. By the time Lin caught the pattern, the vendor had overbilled $2,800 across four invoices. The same quarter, a duplicate invoice for a $4,200 media placement slipped through because the vendor sent it from two different email addresses with different invoice numbers. Total leakage that quarter: $14,000.

Lin tracks everything in a 47-column Airtable base. Purchase orders in one table, invoices in another, expense categories in a third. She has built views, formulas, and automations over two years. None of them can read a PDF, compare line items to a PO, or text a budget owner at 3 PM asking them to approve a $12,000 production invoice before the vendor's net-30 deadline.

The math: Lin plus one AP associate, 35 hours per month on invoice processing. $14K in undetected overpayments per quarter. Three late-payment fees totaling $1,800 because approvals sat in someone's inbox for 11 days. Lin did not need a new spreadsheet. She needed an agent that reads invoices, validates them against POs, and gets approvals before the deadline.

# The Build: Event-Driven Invoice Processing Agent

The automation has four pieces:

1. **`entity-types/invoice.ts`** stores every incoming invoice with vendor info, line items, PO reference, validation status, and approval state
2. **`tools/index.ts`** has a custom tool that matches invoice line items against purchase orders in Airtable and returns discrepancies
3. **`agents/invoice-processor.ts`** validates invoices, categorizes expenses, flags mismatches, and sends approvers a WhatsApp summary
4. **`triggers/invoice-created.ts`** fires when a new invoice entity is created, dispatching the agent to process it immediately

When an invoice arrives — parsed from email or entered manually — a new entity gets created in Struere. The trigger fires. The agent pulls the matching PO from Airtable, compares every line item, categorizes expenses, and sends a WhatsApp message to the assigned approver with a clean summary and any discrepancy flags. Clean invoices get approved in one tap. Flagged invoices include the exact line items that do not match, so the approver can decide in 30 seconds instead of 45 minutes.

## The Data Layer: What Gets Stored

Every invoice creates a record with vendor details, the full line-item breakdown, PO matching results, and approval tracking.

```typescript
import { defineData } from "struere"

export default defineData({
  name: "Invoice",
  slug: "invoice",
  schema: {
    type: "object",
    properties: {
      invoiceNumber: {
        type: "string",
        description: "Vendor's invoice number (e.g., INV-2026-0842)",
      },
      vendorName: {
        type: "string",
        description: "Name of the vendor or supplier",
      },
      vendorEmail: {
        type: "string",
        format: "email",
        description: "Vendor contact email for follow-ups",
      },
      poNumber: {
        type: "string",
        description: "Matching purchase order number from Airtable",
      },
      lineItems: {
        type: "array",
        items: {
          type: "object",
          properties: {
            description: { type: "string" },
            quantity: { type: "number" },
            unitPrice: { type: "number" },
            total: { type: "number" },
            expenseCategory: {
              type: "string",
              enum: [
                "media-buy",
                "freelance",
                "print-production",
                "software",
                "event-venue",
                "travel",
                "office-supplies",
                "other",
              ],
            },
          },
        },
        description: "Individual line items from the invoice",
      },
      totalAmount: {
        type: "number",
        description: "Invoice total in USD",
      },
      validationStatus: {
        type: "string",
        enum: ["pending", "matched", "discrepancy", "duplicate", "no-po-found"],
        description: "Result of PO validation",
      },
      discrepancies: {
        type: "array",
        items: {
          type: "object",
          properties: {
            lineItem: { type: "string" },
            invoiceAmount: { type: "number" },
            poAmount: { type: "number" },
            difference: { type: "number" },
          },
        },
        description: "List of line items that do not match the PO",
      },
      approverPhone: {
        type: "string",
        description: "Budget owner WhatsApp number in E.164 format",
      },
      approvalStatus: {
        type: "string",
        enum: ["awaiting", "approved", "rejected", "escalated"],
        description: "Current approval state",
      },
      dueDate: {
        type: "string",
        description: "Payment due date in ISO format (YYYY-MM-DD)",
      },
    },
    required: [
      "invoiceNumber",
      "vendorName",
      "totalAmount",
      "validationStatus",
    ],
  },
  searchFields: ["invoiceNumber", "vendorName", "poNumber"],
  displayConfig: {
    titleField: "invoiceNumber",
    subtitleField: "vendorName",
    descriptionField: "validationStatus",
  },
})
```

The `validationStatus` enum captures every outcome of the PO matching process. `matched` means every line item aligns. `discrepancy` means at least one line differs. `duplicate` means the agent found an existing invoice with the same vendor and amount in the last 90 days. `no-po-found` means no purchase order exists for this vendor — which is a red flag in itself.

The `discrepancies` array stores the exact delta for each mismatched line item so the approver sees "$0.42 vs $0.38 on brochures, difference: $200" instead of "something does not match."

## The Custom Tool: PO Matching via Airtable

One custom tool queries the Airtable PO database, finds the matching purchase order, and compares line items against the invoice. This is the core validation logic.

```typescript
import { defineTools } from "struere"

export default defineTools([
  {
    name: "match_invoice_to_po",
    description:
      "Look up a purchase order in Airtable by PO number or vendor name, compare line items against the invoice, and return discrepancies",
    parameters: {
      type: "object",
      properties: {
        poNumber: {
          type: "string",
          description: "Purchase order number to look up",
        },
        vendorName: {
          type: "string",
          description: "Vendor name to search if PO number is not available",
        },
        invoiceLineItems: {
          type: "array",
          items: {
            type: "object",
            properties: {
              description: { type: "string" },
              quantity: { type: "number" },
              unitPrice: { type: "number" },
              total: { type: "number" },
            },
          },
          description: "Line items from the invoice to validate",
        },
      },
      required: ["invoiceLineItems"],
    },
    handler: async (args, context, struere, fetch) => {
      const poNumber = args.poNumber as string | undefined
      const vendorName = args.vendorName as string | undefined
      const invoiceLineItems = args.invoiceLineItems as Array<{
        description: string
        quantity: number
        unitPrice: number
        total: number
      }>

      let poRecords
      if (poNumber) {
        poRecords = await struere.airtable.listRecords({
          baseId: process.env.AIRTABLE_PO_BASE_ID!,
          tableId: "Purchase Orders",
          filterByFormula: `{PO Number} = '${poNumber}'`,
          maxRecords: 1,
        })
      } else if (vendorName) {
        poRecords = await struere.airtable.listRecords({
          baseId: process.env.AIRTABLE_PO_BASE_ID!,
          tableId: "Purchase Orders",
          filterByFormula: `{Vendor} = '${vendorName}'`,
          sort: [{ field: "Created", direction: "desc" }],
          maxRecords: 5,
        })
      }

      if (!poRecords || poRecords.records.length === 0) {
        return { status: "no-po-found", discrepancies: [] }
      }

      const po = poRecords.records[0]
      const poLineItems = po.fields["Line Items"] as Array<{
        description: string
        quantity: number
        unitPrice: number
        total: number
      }>

      const discrepancies: Array<{
        lineItem: string
        invoiceAmount: number
        poAmount: number
        difference: number
      }> = []

      for (const invItem of invoiceLineItems) {
        const poItem = poLineItems?.find(
          (p) =>
            p.description.toLowerCase().includes(invItem.description.toLowerCase()) ||
            invItem.description.toLowerCase().includes(p.description.toLowerCase())
        )

        if (!poItem) {
          discrepancies.push({
            lineItem: invItem.description,
            invoiceAmount: invItem.total,
            poAmount: 0,
            difference: invItem.total,
          })
          continue
        }

        if (Math.abs(invItem.unitPrice - poItem.unitPrice) > 0.01) {
          discrepancies.push({
            lineItem: invItem.description,
            invoiceAmount: invItem.unitPrice * invItem.quantity,
            poAmount: poItem.unitPrice * poItem.quantity,
            difference:
              invItem.unitPrice * invItem.quantity -
              poItem.unitPrice * poItem.quantity,
          })
        }
      }

      return {
        status: discrepancies.length > 0 ? "discrepancy" : "matched",
        poNumber: po.fields["PO Number"],
        poTotal: po.fields["Total"],
        approverName: po.fields["Budget Owner"],
        approverPhone: po.fields["Budget Owner Phone"],
        discrepancies,
      }
    },
  },
])
```

The tool does fuzzy matching on line item descriptions — "5000 x Tri-fold Brochures" should match "Tri-fold Brochure printing (5,000 units)" in the PO. The agent handles the nuance; the tool handles the data retrieval and arithmetic. Discrepancies include the exact dollar difference so the agent can tell the approver whether it is a $4 rounding issue or a $2,800 pricing dispute.

## The Agent: Validate, Categorize, Notify

The agent processes every new invoice through a three-step workflow: validate against the PO, categorize expenses, and notify the approver. The system prompt encodes the full decision tree.

```typescript
import { defineAgent } from "struere"

export default defineAgent({
  name: "Bright Arc Invoice Processor",
  slug: "invoice-processor",
  version: "1.0.0",
  model: {
    model: "anthropic/claude-sonnet-4-6",
    temperature: 0.2,
    maxTokens: 4096,
  },
  tools: [
    "entity.query",
    "entity.update",
    "whatsapp.send",
    "email.send",
    "match_invoice_to_po",
  ],
  systemPrompt: `You are the accounts payable processor for Bright Arc, a marketing agency.
Current time: {{currentTime}}

## Processing Workflow

When you receive an invoice to process:

### Step 1 — Duplicate Check
Use entity.query to search for existing invoices with the same vendorName and totalAmount created in the last 90 days. If you find a match with a different invoiceNumber, flag this invoice as "duplicate" and stop processing. Update the entity with validationStatus "duplicate".

### Step 2 — PO Matching
Use match_invoice_to_po with the PO number (if provided) or vendor name. Pass all invoice line items for comparison.
- If no PO found: update validationStatus to "no-po-found". Notify the approver that this invoice has no matching purchase order.
- If PO found with no discrepancies: update validationStatus to "matched".
- If discrepancies found: update validationStatus to "discrepancy". Include each discrepancy in the entity.

### Step 3 — Expense Categorization
Categorize each line item using these rules:
- Google/Meta/LinkedIn ad spend -> "media-buy"
- Freelance design/copy/photography -> "freelance"
- Printing, signage, branded materials -> "print-production"
- SaaS tools, software licenses -> "software"
- Venue rental, catering, AV equipment -> "event-venue"
- Flights, hotels, transportation -> "travel"
- Office supplies, furniture -> "office-supplies"
- Everything else -> "other"

Update the entity with categorized line items.

### Step 4 — Approver Notification
Send a WhatsApp message to the approver (phone from PO data or the entity's approverPhone field).

For matched invoices:
"[Bright Arc AP] Invoice [invoiceNumber] from [vendorName] for $[totalAmount] matches PO [poNumber]. Expenses: [category breakdown]. Due: [dueDate]. Reply APPROVE to confirm."

For discrepancy invoices:
"[Bright Arc AP] Invoice [invoiceNumber] from [vendorName] has [N] discrepancies vs PO [poNumber]:
- [lineItem]: invoiced $[amount] vs PO $[amount] (diff: $[diff])
Total invoice: $[totalAmount] vs PO: $[poTotal]. Due: [dueDate]. Reply APPROVE to accept or REJECT to dispute."

For no-PO invoices:
"[Bright Arc AP] Invoice [invoiceNumber] from [vendorName] for $[totalAmount] has no matching purchase order. Please verify and reply APPROVE or REJECT."

### Step 5 — Vendor Confirmation (matched invoices only)
For matched and approved invoices, send a confirmation email to the vendor via email.send:
Subject: "Bright Arc — Invoice [invoiceNumber] Received"
Body: confirm receipt, state the expected payment date based on net-30 from invoice date.

## P0 — Accuracy
- Never approve an invoice yourself. You flag, categorize, and notify.
- Never modify invoice amounts. Report exactly what the vendor submitted.
- If a discrepancy is under $5, still flag it. Rounding errors compound.
- Always show the exact dollar difference, not percentages.

## P1 — Tone
- WhatsApp messages: concise, professional, numbers-first.
- Vendor emails: formal, courteous, include invoice number in subject line.
- Do not use emoji. Do not use casual language.

Never fabricate PO data. Never assume a vendor email is correct without checking the entity.
Never send payment confirmations — only receipt confirmations.`,
})
```

Temperature 0.2. Invoice validation is pure arithmetic and pattern matching. The agent should not get creative with expense categories or discrepancy thresholds. Every dollar matters.

Five tools. Entity query for duplicate detection, entity update for status changes, WhatsApp for approver notifications, email for vendor confirmations, and the custom PO matching tool. At the tool ceiling but each one is essential.

## The Trigger: Fire When an Invoice Arrives

The trigger watches for new invoice entities. The moment one is created — whether parsed from an inbound email or entered manually through the dashboard — the agent processes it.

```typescript
import { defineTrigger } from "struere"

export default defineTrigger({
  name: "Process New Invoice",
  slug: "invoice-created",
  on: {
    entityType: "invoice",
    action: "created",
  },
  retry: { maxAttempts: 3, backoffMs: 10000 },
  actions: [
    {
      tool: "agent.chat",
      args: {
        agentSlug: "invoice-processor",
        message:
          "New invoice received. Invoice number: {{trigger.data.invoiceNumber}}, vendor: {{trigger.data.vendorName}}, total: ${{trigger.data.totalAmount}}, PO reference: {{trigger.data.poNumber}}, line items: {{trigger.data.lineItems}}, due date: {{trigger.data.dueDate}}. Process this invoice: check for duplicates, validate against the PO, categorize expenses, and notify the approver.",
      },
    },
  ],
})
```

No condition filter needed. Every new invoice gets processed. The agent handles the logic — duplicates, missing POs, discrepancies, clean matches — all inside its workflow. The trigger is the dispatch mechanism.

Retry is set to 3 attempts with 10-second backoff. Airtable occasionally rate-limits during high-volume periods (month-end when 60+ invoices land in the same afternoon), and the longer backoff gives the API time to recover.

# Debugging: Three Things That Broke

**The PO matching tool returned "no-po-found" for invoices that had valid POs.** The vendor's invoice listed "Acme Design Co." but the Airtable PO had the vendor as "Acme Design Company." The exact string match in the Airtable formula failed. Twelve invoices in the first week were incorrectly flagged as having no matching purchase order. The approvers started ignoring the "no PO" notifications because they assumed the system was wrong.

Fix: we switched the Airtable filter from exact match to `FIND()` for partial matching and added a fallback that searches by invoice amount range (+/- 10%) when the vendor name match fails. The agent now finds the PO even when the vendor name has minor variations.

```bash
struere run-tool match_invoice_to_po --args '{"vendorName": "Acme Design Co.", "invoiceLineItems": [{"description": "Logo redesign", "quantity": 1, "unitPrice": 4500, "total": 4500}]}'
```

The output returned `{ status: "no-po-found" }`. After updating the formula to use `FIND(LOWER('Acme Design'), LOWER({Vendor}))`, the same call returned the matching PO with zero discrepancies.

**Duplicate detection flagged recurring monthly invoices as duplicates.** Bright Arc pays the same $2,400/month to three SaaS vendors. Same vendor, same amount, every month. The agent's 90-day duplicate window caught the second month's invoice and flagged it as duplicate. Lin had to manually override 9 invoices in the first month.

Fix: we tightened the duplicate check logic in the system prompt to require both matching amount AND matching invoice number prefix pattern. Monthly recurring invoices from the same vendor have sequential numbers (INV-2026-0041, INV-2026-0042). A true duplicate has the exact same invoice number or was created within 48 hours with the same amount. The agent now distinguishes between "same vendor, same amount, different invoice" (recurring) and "same vendor, same amount, suspiciously similar timing" (duplicate).

```bash
struere logs list --last 30
```

The conversation logs showed the agent flagging `INV-2026-0042` as a duplicate of `INV-2026-0041` with the note "Same vendor (Figma) and amount ($2,400) found within 90 days." After the prompt fix, the agent correctly processes recurring invoices and only flags true duplicates.

**WhatsApp approval messages were sent to the wrong approver for multi-department invoices.** A single vendor invoice covered both a media buy ($8,000, owned by the media team lead) and event catering ($3,200, owned by the events manager). The PO lookup returned the media team lead's phone number because the media PO was created first. The events manager never saw the $3,200 charge.

Fix: when an invoice maps to multiple POs across departments, the agent now sends separate WhatsApp notifications to each budget owner, listing only their relevant line items. The tool returns all matching POs, not just the first one, and the agent splits notifications by approver.

```bash
struere triggers logs invoice-created --verbose
```

The trigger log showed the agent.chat step sending one WhatsApp message to the media lead with the full $11,200 total. After the fix, the same invoice generates two messages — $8,000 to the media lead and $3,200 to the events manager — each with only their relevant line items.

# Setup: From Zero to Running

Install the CLI and authenticate:

```bash
bun install -g struere
struere login
struere init
```

Scaffold the resources:

```bash
struere add data-type invoice
struere add agent invoice-processor
struere add trigger invoice-created
```

Edit `entity-types/invoice.ts` with the schema shown above. Customize the `expenseCategory` enum to match your chart of accounts. If your vendors use different invoice number formats, adjust the `invoiceNumber` description.

Edit `tools/index.ts` with the PO matching tool. Set `AIRTABLE_PO_BASE_ID` in the dashboard under Settings > Environment Variables. Your Airtable base needs a "Purchase Orders" table with columns: PO Number, Vendor, Total, Line Items (JSON or linked records), Budget Owner, Budget Owner Phone.

Edit `agents/invoice-processor.ts` with your company name, expense categories, and approval notification format. Customize the categorization rules for your industry.

Edit `triggers/invoice-created.ts` with the trigger definition. No condition changes needed unless you want to filter by vendor or amount threshold.

Add your Airtable personal access token under Integrations > Airtable. This connects the PO matching tool to your purchase order database.

Connect WhatsApp in the dashboard under Integrations > WhatsApp. This enables approval notifications to budget owners.

Configure Resend under Integrations > Email with your API key and sender address. This powers the vendor receipt confirmation emails.

Sync and start watching:

```bash
struere dev
```

Test by creating a test invoice:

```bash
struere run-tool entity.create --args '{"type": "invoice", "data": {"invoiceNumber": "TEST-001", "vendorName": "Acme Design Co.", "totalAmount": 4500, "validationStatus": "pending", "poNumber": "PO-2026-0122", "lineItems": [{"description": "Logo redesign", "quantity": 1, "unitPrice": 4500, "total": 4500}], "dueDate": "2026-04-30"}}'
```

Watch the trigger fire and the agent process the invoice:

```bash
struere triggers logs invoice-created
```

Verify the invoice was updated with validation results:

```bash
struere data list invoice
```

Create a test entity with a known discrepancy and watch the agent flag it — that is when you know the system works.

# What Changed

| Metric | Before | After |
|--------|--------|-------|
| Invoice processing time | 8-12 min each | Under 30 seconds (automated) |
| Discrepancy resolution | 45 min average | 2 min (pre-flagged with exact amounts) |
| Monthly hours on AP | 35 hours | 3 hours (escalations and exceptions) |
| Undetected overpayments | $14K/quarter | $0 last quarter |
| Duplicate invoices caught | ~60% (manual review) | 100% (automated check) |
| Late payment fees | $1,800/quarter | $0 (approvals happen same day) |
| Approver response time | 4-11 days | Under 4 hours (WhatsApp) |

Lin still reviews the weekly AP summary every Monday. But instead of opening 200 invoices, she opens a dashboard showing 8-12 flagged items that need her judgment — the discrepancies over $500, the invoices with no PO, the vendors she has never seen before. The other 190 invoices were validated, categorized, approved, and confirmed without her touching them.

The print vendor that overbilled $2,800 across four invoices would not survive a single invoice now. The agent catches a $0.04/unit price difference on the first invoice, flags it with the exact dollar impact, and sends the approver a WhatsApp message with the PO comparison. The approver rejects it. Lin sends the vendor a correction request. The $2,800 never accumulates.

The AP associate who spent 35 hours a month processing invoices now spends that time on vendor negotiations and early-payment discount analysis. Last month, she negotiated 2% net-10 terms with three vendors — saving Bright Arc $4,100 in a single month. That is what happens when you stop spending finance talent on data entry and start spending it on strategy.
