Skip to content

Accounts Payable — Worked Example

The accounts-payable agent is deliberately recommendation-first. It can build a payment plan, name risk gates, pick a Reap rail, and estimate foreign-exchange cost. It cannot move money by itself. This page walks two live rows — one duplicate invoice and one beneficiary-bank-change — and shows the deterministic plan the agent builds for a clean bill underneath.

Live row walkthrough: duplicate invoice

A Vietnamese contractor — Quang Nguyen Engineering — bills the same VND amount twice in four days. The first invoice was already in the AP queue when the second arrived. Without the duplicate gate, both would have been scheduled and paid; the recovery work is real money out the door and a vendor-credit cycle to claw it back.

Quang Nguyen Engineering duplicate pair: BILL-25807 and BILL-11541, both Vietnamese dong 107,594,937, four days apart.

LayerDetail
What the user seesThe controller sees a hold: possible duplicate invoice. The row names the prior bill and asks for confirmation before payment.
Event rowsTwo bill events from email-bill, same vendor, same currency, same amount, inside the seven-day duplicate window.
Decision rowdecisions.skill = "ap-agent", outcome_kind = "escalate", role controller, payload reason duplicate_detected.
Evidencedup_invoice evidence records the prior bill identifier and prior occurrence date.
Reap implicationReap Pay can prevent duplicate outbound payments before they become recall or vendor-credit work.

The "no scheduled payment date" detail in the decision row is important. A weaker version of this gate would still compute the payment plan and flag the duplicate alongside it — but then a one-click "approve" on the queue row would push the plan into execution. By withholding the plan entirely on a fraud hold, the controller's only available action is "investigate first."

How the duplicate test fires

findDuplicate in skill.ts scans every bill event for the tenant in a 7-day window around the new bill. All four criteria must hold for a match:

vendorvendor.id equalSame supplier — different vendors with coincidentally equal amounts don't count.
currencytotal.currency equalA 107,594,937 USD bill and a 107,594,937 VND bill are not duplicates.
amounttotal.amountMinor equalExact minor-units match; no fuzzy tolerance.
window|Δ occurredAt| ≤ 7 daysResends typically happen within days; quarterly bills with identical totals don't trip this.

The four-attribute exact match is deliberately loose on description and line items but strict on amount. Real duplicate resends almost always preserve the total; legitimate same-amount, same-vendor bills 7+ days apart fall outside the window. False positives go to the controller — a single keystroke confirms-and-pays, costlier than the rare confused-vendor case.

Infra error escalates — never falls through

If the DB read fails, the skill escalates with reason: "fraud_check_unavailable" (skill.ts:181-195) rather than silently scheduling. The textbook silent-failure on a money path is "DB hiccup → fraud check returns null → bill gets paid." That path is structurally closed here.

Live row walkthrough: bank account change

This is the textbook invoice-fraud vector: an attacker compromises a vendor's email, sends a "corrected" invoice with their own bank details, and waits. Halborn — a security firm the tenant works with — has been paid five times to a Chase account; this invoice asks for payment to a Wells Fargo account instead. Nothing else about the invoice changed: same amount range, plausible due date, identical vendor identity. The only signal is the account.

Halborn invoice: BILL-87651, United States dollar 18,986.00, new Automated Clearing House account WELLS-****9982.

Halborn BILL-87651 escalated in the operator queue: red bar, escalate verdict, plain-English rationale citing the prior bank account and the new one.
The live row — escalate verdict, plain-English rationale comparing this invoice's WELLS-****9982 against the two prior payments to CHASE-****4521. No scheduled payment until a human confirms.
LayerDetail
What the user seesThe row says Halborn previously used CHASE-****4521, but this invoice asks for a different bank account. The controller must verify through a known channel.
Event rowevents.kind = "bill", vendor Halborn, due date 2026-06-05, amount United States dollar 18,986.00.
Decision rowoutcome_kind = "escalate", payload reason bank_account_changed, no scheduled payment date.
Evidencebank_account_change evidence stores the new account, known account, and prior count.
Reap implicationReap can become the control plane for vendor-payment fraud, not only the rail that executes a payment.

"Verify through a known channel" is the prescribed playbook — the controller calls a phone number they already have for Halborn, not the one on the suspect invoice. This is confirmation-of-payee, and it is the only check that actually stops business-email-compromise attacks. The agent's job is not to verify; it is to make the verification step structurally required before the payment can proceed.

How the bank-change test fires

findBankAccountChange looks back 90 days of prior bills for the same vendor, counts each unique vendorBankAccount value, and takes the modal (most-frequent) account as the "known" one. The new bill is flagged when its account ≠ the modal account.

Three subtleties:

New vendor → no flagcounts.size === 0 returns null. A first-ever bill cannot be a "change."Avoids false positives on first-time suppliers.
Ties resolved deterministicallyWhen two accounts have equal counts in the window, the lexicographically-first one wins. Rare in practice but documented in code.Test outcomes are reproducible across machines.
Reviewer sees knownCountThe escalation message includes "differs from the 5 prior payments to Halborn" so the controller has confidence calibration, not just "this is new."Risk signal scales with vendor history depth.

Why 90 days

Long enough for quarterly vendors, short enough that a legitimate banking change made 6 months ago becomes the new modal account before the test runs. The window is tunable per tenant but rarely needs to move.

Backend path

The pipeline is intentionally front-loaded with safety. Fraud checks run in stage 2, before anything that resembles optimization — so there is no "we computed an elegant plan, but the vendor's bank account changed" race. Stages 3a–3c are deterministic finance math; stage 4 is the only place a decision row gets written.

1
Bill eventThe bill enters as an email-derived event with vendor, amount, due date, line items, payment terms, and optional vendor bank account.
2
Fraud checks firstDuplicate and bank-account-change checks run before scheduling. Infra failure on either check escalates rather than falling through.
↓ clean
3a
SchedulescheduleBill picks the payment date (default: due − 1 banking day) or shifts to the early-pay discount cutoff if net-positive.
3b
Pick source of fundspickSource picks Card / Pay / Optimize, then verifies the sleeve covers the bill at current balances and falls through if not.
3c
Estimate FX costestimateFx looks up the bps for the chosen rail and the bill's currency; same-currency is 0.
4
Decision logSafe plans become suggest (confidence 0.82 normal, 0.75 due-soon). Overdue, duplicate, bank-change, or underfunded cases become escalate.

The 0.82-vs-0.75 confidence split mirrors how much judgement the controller has to add on top of the plan. A scheduled bill (days out, no overdue pressure) is almost entirely mechanical — pick the date, pick the rail, pick the FX — so the recommendation is high-confidence. A due_soon bill compresses the safety margin: rail latency matters more, and the controller may need to break-glass an alternate sleeve if balances shift in the next 24 hours. The confidence reflects "how confident am I that you should approve this without thinking," not "how likely is the plan to settle."

The clean-bill payment plan

When all fraud checks pass, the agent produces a payload that powers the bills page below — scheduled count, overdue count, total committed, and the expected FX cost across all routes all come from the same deterministic plan.

Bills page showing $178k committed across 10 scheduled bills, 3 overdue, and an FX-cost estimate of $79 across all routes; below, the AP-deferral scenario lists three low-discount bills with +0d runway impact.
The /bills surface — $178k committed across 10 scheduled, 3 overdue, FX cost $79. The "delay low-discount bills" scenario is the same counterfactual the daily pulse runs — same code path, different surface.

The agent payload behind one row looks like this:

ts
{
  paymentDate: "2026-05-20",
  urgency: "scheduled",         // overdue | due_soon | scheduled
  daysUntilDue: 7,
  sourceOfFunds: "reap-optimize", // reap-card | reap-pay | reap-optimize
  amount: { amountMinor: 1898600, currency: "USD" },
  expectedFxCostUsdMinor: 571,
  bps: 30,
  reason: "Draw from Reap Optimize yield balance — preserves working capital and earns until cutoff.",
  discount: {                     // optional, only when net-positive
    cutoffDate: "2026-05-15",
    daysEarlier: 4,
    discountSavingsUsdMinor: 37972,
    yieldForegoneUsdMinor: 936,
    netUsdMinor: 37036
  }
}

Every field on the payload is computed deterministically; nothing comes from a model. The reason string is templated from the funding choice and discount math (skill.ts:326-345), so it can be replayed offline against a future codebase version.

How paymentDate is chosen

scheduleBill (scheduler.ts) defaults to one banking day before due — close enough to keep cash earning yield in Optimize, far enough to absorb rail latency. Three branches:

overdueDays < 0Pay today; escalates to controller for immediate ratification.
due_soonDays 0–2Pay today; rail latency leaves no safety margin.
scheduledDays ≥ 3Default: due − 1 banking day. Shifts forward only if an early-pay discount nets positive against yield foregone.

How the early-pay discount math works

evaluateDiscount parses the payment terms ("2/10 net 30") and compares discount savings against yield foregone:

discountSavingsUsdMinor = totalUsd × discountPct / 100
yieldForegoneUsdMinor   = totalUsd × FUNDING_APR × daysEarlier / 365
netUsdMinor             = discountSavings - yieldForegone

FUNDING_APR is 4.5% (Reap Optimize ballpark, tunable per tenant). Take the discount only when net > 0; otherwise the default schedule wins. The math is in dollar terms on the row, not narrated — the controller sees $37,972 savings vs $936 yield = $37,036 net rather than "this is a good deal."

The parser is strict: "net 30" returns null, "2/10 net 30" parses, anything malformed returns null. We'd rather miss a discount than misread one (discount.ts:14-33).

How sourceOfFunds is chosen

pickSource (funding.ts) computes a preference order based on bill shape, then walks the list and picks the first sleeve that actually covers the bill at current balances.

Reap PayCross-currencyvendor currency ≠ funding currency → USDC corridor avoids double FX
Reap CardSame-currency ≤ USD 5kfastest settle and rewards path; mirrors corporate-card single-txn limit
Reap OptimizeSame-currency > USD 5kdraw yield balance until cutoff; preserves working capital

The fallback chain is the production-relevant detail. A cross-currency bill prefers Pay (USDC corridor, ~30 bps), but if Pay is short, the picker walks down to Optimize. If no sleeve covers, funding.insufficient = true and the skill escalates with the shortfall in dollars (skill.ts:270-288) rather than recommending a draw that can't settle.

USD 5,000 is a knob, not a law

The threshold mirrors corporate-card single-txn limits — the point where convenience stops being worth the working-capital cost. Tunable per tenant in production.

How FX cost is estimated

estimateFx (fx.ts) uses a deterministic bps table per rail. Same-currency bills always come back as 0 cost.

Reap Pay (USDC corridor)~30 bps round-trip
Reap Optimize (routed via Pay)~30 bps
Reap Card (Visa cross-currency)~150 bps

expectedFxCostUsdMinor = round(usdMinor × bps / 10000). The 30 bps figure for Pay is round-trip on the USDC corridor; 150 bps for Card matches Visa's published cross-currency margin. These numbers are tunable but stable enough across customers that the deterministic estimate is more useful than a real-time quote that varies by 2 bps per minute.

Why the agent stops instead of optimizing

The user does not need an elegant payment date when the beneficiary account changed. The right product behavior is to stop, explain the risk, and keep the payment unscheduled until a controller verifies it. That is why the risk gates run before source-of-funds optimization.

Money movement cannot auto-apply — enforced at the type level

The skill's type signature does not include autoApply as a return value. Even at the autonomous tenant tier, the strongest outcome this skill can produce is suggest or escalate. The autonomy gate cannot promote a money-movement decision to auto-apply because there is no auto-apply outcome for it to promote to. Enforced at compile time, not by a runtime check that someone could forget to wire up.

Risk gates summarised

Nine gates, colour-coded by behaviour. Red gates stop the bill (refuse or escalate with no plan). Gold gates escalate but attach the plan so the controller can act with full context. Green gates inform downstream choices without blocking.

Missing dueAtrefuse(missing_input) — cannot schedule a bill without a due date.
Duplicate invoicevendor + currency + amount + ≤7d → escalate duplicate_detected. No payment date.
Bank-account changevs. modal in 90d → escalate bank_account_changed. No payment date.
Fraud check infra errorDB read fails → escalate fraud_check_unavailable. Never fall through.
Dual control (USD ≥ 10k)Mark the suggest payload; controller execution requires two approvers downstream.
Overdue (days < 0)Escalate with the computed payment plan attached.
Sleeve shortfallNo rail covers → escalate with the shortfall in dollars; do not recommend a draw that fails.
Discount windowInform the schedule; never blocks.
Balance-aware funding fallbackInform the source-of-funds choice; never blocks.

Design patterns

The risk gates and the rail picker are not ad-hoc code — they are five named patterns doing visible work. Two of them (Fail-fast, Dual Control) are why this skill cannot move money on its own; the other three keep the payment plan itself coherent.

Fail-fast / Guard ClausesFraud checks (duplicate detection, bank-account-change hold) run before scheduling and source-of-funds optimization. If a guard fires, the skill escalates immediately rather than computing a payment plan for a potentially fraudulent bill.Risk gates cannot be skipped by normal flow; escalation is the only exit.
StrategyThe source-of-funds picker selects a payment rail (Reap Card, Reap Pay, Reap Optimize) based on amount, currency corridor, and available balance. Each rail is an interchangeable strategy behind the same interface.Rail logic is isolated and testable per strategy; adding a new rail requires no changes to scheduling or FX estimation.
State MachineThe approval workflow progresses through explicit states — pending, in-review, approved, escalated — with guarded transitions. High-value bills cannot advance to approved without passing the dual-control gate.Illegal state transitions are prevented structurally, not by runtime checks scattered across the codebase.
Saga / Process ManagerThe payment plan is a multi-step sequence (schedule → fund → approve → execute) where each step can fail independently. The skill produces a suggest or escalate outcome rather than partial execution; compensation is always human-ratified.No partial payment state; the plan is either fully approved or fully held.
Dual ControlPayments at or above USD 10k require two authorized parties before execution. The skill marks the decision as requiring dual control and surfaces it to the operator; it cannot unilaterally advance past that gate.High-value money movement cannot happen on a single agent decision.

The two patterns from finance regulation — Dual Control and Fail-fast — are the ones a controller will recognise first. The three software patterns — Strategy, State Machine, Saga — are how the regulation patterns get implemented without the code growing into a tangle.

Files to inspect

FileWhy it matters
src/skills/ap-agent/skill.tsDuplicate detection (7d window), bank-account-change detection (90d modal), outcome mapping, decision evidence, and the type-level safety property that no payload can ever return autoApply.
src/skills/ap-agent/scheduler.tsDue-date scheduling with the "due − 1 banking day" default and the early-pay discount shift. UTC-anchored day arithmetic.
src/skills/ap-agent/discount.ts"N/D net T" payment-terms parser and the net-positive discount evaluation (FUNDING_APR = 4.5%).
src/skills/ap-agent/funding.tsSource-of-funds picker with balance-aware fallback and explicit insufficient shortfall flag.
src/skills/ap-agent/fx.tsDeterministic bps table per rail; same-currency returns 0.
evals/ap-agent.jsonlGolden cases for clean schedule, duplicate, bank change, overdue, discount window, sleeve shortfall, and missing-due-date refusal.

Submission pack — Reap Chief Financial Officer Agent take-home