AI Invoice Reconciliation in NetSuite — SuiteScript Pattern
- AI invoice reconciliation uses an LLM to match vendor invoice line items to PO expectations and flag discrepancies.
- The pattern scales past 10,000 invoices/month on Map/Reduce because each invocation gets its own governance budget instead of sharing one batch budget.
- Confidence scoring auto-approves high-confidence matches and routes everything else to an exception queue for human review.
- Cost at scale: a fraction of a cent per invoice at current small-model API pricing — well under one minute of AP staff time. Worked math and verified rates are below.
Matching vendor invoices to purchase orders is one of the highest-volume manual tasks in accounts payable. When a 50-line vendor invoice arrives, AP staff compare each line to the PO — quantities, unit prices, descriptions, totals. At 10,000 invoices a month that comparison work is a full-time job on its own. This pattern replaces the comparison step with an LLM call inside a SuiteScript Map/Reduce script and routes only the exceptions to a human. The rest of this guide covers the parts that actually break in production: what happens when the AI call times out mid-invoice, how the exception queue avoids losing a bill, how a governance-exhaustion error mid-batch gets handled without reprocessing everything, and what the pattern actually costs at current model pricing — verified, not estimated from memory.
Architecture Overview
Five stages, in order: getInputData() runs once and returns every Vendor Bill flagged PENDING; map() runs once per bill and does the actual comparison work; a shuffle stage (automatic, no code) groups map output by key; reduce() runs once per key and aggregates; summarize() runs once at the end. The governance win is structural: a scheduled script shares one 10,000-unit budget across every record it touches, but each Map/Reduce map() and reduce() invocation gets its own budget — one slow, line-heavy invoice cannot starve the units the next 200 invoices in the batch need.
The AI Prompt Pattern
The key to reliable results is a structured prompt with an explicit JSON output format. Do not ask the model to write prose — ask it to return a JSON object with a defined schema the code can parse deterministically, and validate the response against that schema before writing anything to NetSuite.
You are a purchase order reconciliation engine.
Compare the VENDOR INVOICE lines to the PURCHASE ORDER lines.
For each invoice line, return a JSON object with:
{
"invoice_line": number,
"match_status": "MATCH" | "QUANTITY_MISMATCH" | "PRICE_MISMATCH" | "UNMATCHED",
"confidence": number (0.0-1.0),
"discrepancy_amount": number (0 if matched),
"notes": string (brief explanation if mismatch)
}
VENDOR INVOICE LINES:
{invoice_lines_json}
PURCHASE ORDER LINES:
{po_lines_json}
Return ONLY a JSON array of line match objects.
Choosing a Model for This Workload
This is a structured-extraction and classification task, not open-ended reasoning — the model reads two short JSON arrays and returns a third one against a fixed schema. That workload does not need a frontier-tier model. As of August 2026, Anthropic’s published API pricing lists Claude Haiku 4.5 at $1 per million input tokens and $5 per million output tokens — the fastest current Claude model, and the right tier for a per-line comparison task running thousands of times an hour. Reserve a larger model (Claude Sonnet 5 or above) for cases the schema itself flags as ambiguous, such as free-text line descriptions the vendor formats inconsistently.
Model names and prices move on a monthly-or-faster cadence across every vendor, so pin the exact model ID in the deployment record, not just a family name — “Claude Haiku” without a version number is not a reproducible configuration. The current model landscape, including how vendors handle retirement of older model versions, is covered separately in the current comparison of Claude, GPT, and Gemini for business use; re-check the vendor’s own pricing page before publishing a cost figure, because a figure that was accurate in January is not a safe assumption in August.
A Representative Mismatch
Consider a PO line for 500 units of a SKU at $12.40 each — $6,200 total. The vendor invoice arrives with a line for 480 units at the same $12.40 unit price: $5,952. The unit price matches exactly; the quantity does not. A well-built prompt returns this as a quantity mismatch, not a price mismatch, because the schema forces the model to name which field diverged rather than just flag “different”:
{
"invoice_line": 3,
"match_status": "QUANTITY_MISMATCH",
"confidence": 0.94,
"discrepancy_amount": 248.00,
"notes": "Invoice qty 480 vs PO qty 500 at matching unit price $12.40"
}
The confidence score here is high — the model is not uncertain about what happened, it found a clean, specific discrepancy. High confidence on a mismatch is not the same as high confidence on a match, and the routing logic in the next section treats them differently: a confident mismatch still needs a human to decide whether it is a short shipment, a vendor error, or an approved partial delivery no one recorded in the PO.
Confidence Threshold and Routing
| Confidence | Match status | Action |
|---|---|---|
| 0.95+ | MATCH | Auto-approve — write approval to Vendor Bill, no human needed |
| 0.80–0.95 | MATCH with minor flags | Auto-approve with exception note for AP manager review in batch |
| 0.60–0.80 | Any status | Route to AP queue for human review — exception record created |
| Below 0.60 | Any status | Hold invoice — mandatory human review before any approval |
A confident mismatch (like the example above) and an uncertain match are both routed to a human, but for different reasons — the routing table alone cannot distinguish “the model is sure something is wrong” from “the model could not tell.” Log match_status alongside confidence in the exception record so the AP reviewer sees which case they are looking at before opening the source documents.
Tuning the Threshold in Practice
Start conservative — 0.90 or higher for auto-approval — and lower it only against evidence, not intuition. The evidence is the human override rate: every time an AP reviewer looks at an auto-approved batch note or an exception record and disagrees with the AI’s call, that is a labeled data point. A threshold that is too low shows up as overrides on auto-approved invoices; a threshold that is too high shows up as a human reviewer rubber-stamping exception records that were never actually wrong. Both are measurable from the exception-record log without any additional tooling.
- Run the pattern at a conservative threshold (0.90+) for the first full month before adjusting anything.
- Log every human override of an auto-approved match, with the reason, before touching the threshold.
- Log the share of exception-queue records a reviewer resolves as “actually correct” — a high share means the threshold is too conservative for that vendor.
- Tune per vendor, not globally, if override rates diverge — a vendor with inconsistent invoice formatting earns a higher bar than one with clean EDI feeds.
- Re-run the threshold review after any change to the prompt template or the underlying model version.
- Never lower the threshold to clear a backlog — that converts a staffing problem into an accuracy problem.
The Exception Record and Human Review Loop
Every invoice line below the auto-approve threshold writes a custom exception record, not a note on the Vendor Bill itself — this keeps the AI’s proposed match, its confidence score, and its stated reason queryable and reportable independent of the transaction record. The record carries its own status field, separate from the bill’s custbody_ai_status:
| Exception status | Set by | What happens next |
|---|---|---|
| OPEN | map(), on confidence below threshold |
Appears in the AP review queue, sorted oldest first |
| IN_REVIEW | An AP reviewer opening the record | Locks the record so a second reviewer does not duplicate the work |
| RESOLVED | Reviewer confirms or corrects the match | Vendor Bill status flips to PROCESSED, approval proceeds |
| OVERRIDDEN | Reviewer rejects the AI’s proposed match entirely | Bill routes to standard manual AP handling, outside this pattern |
The loop closes at RESOLVED or OVERRIDDEN — an exception record with no terminal status past a defined age (24 hours is a reasonable start) is itself an alert condition, the same way an unacknowledged incident is in any other operational system. This is the same discipline covered in the incident-response runbook for NetSuite integrations: an exception queue nobody watches is not a safety net, it is a place invoices go to be forgotten.
Idempotency and Retry Handling
An AI API call can fail for reasons that have nothing to do with the invoice: a timeout, a rate limit response, a malformed JSON reply that fails schema validation. The failure mode that matters is what happens if the call actually succeeded on the vendor’s side but the response never made it back — retrying blind risks writing two exception records for one invoice line, or approving the same bill twice if a retry races a still-running first attempt.
The fix is the same idempotency discipline that prevents duplicate Sales Orders on webhook retries: never rely on “did this run before” as a boolean. Before the API call, write a lock timestamp to the bill (custbody_ai_lock_ts) instead of flipping straight to a terminal status. getInputData()‘s query excludes any bill locked within the last N minutes, so a script that reruns before the previous invocation finishes does not grab the same bill twice. A separate scheduled check clears locks older than a timeout (say, 30 minutes) back to PENDING, so a genuinely failed call is retried rather than stuck forever. Only a successful parse-and-write flips the bill past the lock to PROCESSED or creates the exception record — the lock alone is never treated as completion.
Governance Exhaustion Mid-Batch
A single map() invocation that loads an unusually large PO — hundreds of lines, several linked records — can exceed its own governance budget and throw SSS_USAGE_LIMIT_EXCEEDED. Two separate NetSuite mechanisms handle this differently, and conflating them is the most common design mistake in a Map/Reduce build. Yielding is automatic and expected: when a job nears the 10,000-unit ceiling for a stage or the deployment’s Yield After Minutes setting, NetSuite ends that job cleanly and a new job instance resumes the remaining keys — no code required, no error thrown. An uncaught error inside one map() call is different: that invocation ends immediately and the framework moves on to the next key by default, which silently skips the failed bill unless the script configures retryCount and exitOnError to control the retry behavior explicitly.
Every batch-loop code sample in this pattern should call runtime.getCurrentScript().getRemainingUsage() before an expensive step — the AI API call, a second record load — and split the work or exit cleanly if the budget is thin, rather than letting the platform throw mid-invoice. This is the same governance discipline covered in the guide to NetSuite rate limits and retry behavior: the platform will not queue work for you, so the script has to check its own remaining budget rather than assume it has room to finish.
Cost at Scale
Cap prompt plus response at roughly 800 tokens per invoice — enough for a multi-line PO comparison without inviting the model to write prose. Split that budget as roughly 600 input tokens (the invoice and PO line JSON) and 200 output tokens (the structured match array), and price it against Anthropic’s published Claude API rates, checked this session: Claude Haiku 4.5 at $1 per million input tokens and $5 per million output tokens.
(600 ÷ 1,000,000 × $1) + (200 ÷ 1,000,000 × $5) = $0.0006 + $0.0010 = $0.0016 per invoice. At 10,000 invoices a month that is roughly $16/month in model cost — well under the cost of a few minutes of AP staff time. Recompute this with your own token counts and the vendor’s current rate card before publishing an internal cost estimate; per-token prices change, and this figure will drift out of date faster than the architecture around it. If an invoice’s line count pushes it past the token budget, split it into chunks and reconcile in multiple calls rather than truncating the PO data the model sees, which is exactly the kind of scaling question the broader guide to calling AI APIs from SuiteScript covers for other AP and order-processing workloads.
Building this in your account
Exception-record design, lock/retry handling, and threshold tuning are decisions specific to your vendor mix and PO discipline. SoftXone builds and reviews SuiteScript AP automation as part of NetSuite consulting engagements.
References
- SuiteScript 2.x Map/Reduce Script TypeOracle NetSuite Help — Map/Reduce stages, entry points, and the per-invocation governance model.
- Map/Reduce YieldingOracle NetSuite Help — how a job yields and resumes when it nears its governance or time limit.
- Map/Reduce Script Error HandlingOracle NetSuite Help — default behavior on an uncaught error, and the retryCount/exitOnError options.
- Script.getRemainingUsage()Oracle NetSuite Help — the governance-check API every batch loop in this pattern should call.
- Vendor BillOracle NetSuite Help — Vendor Bill record fields and line item structure accessed in the reconciliation script.
- Claude Messages API ReferenceAnthropic — request/response schema for the API call used in the reconciliation prompt pattern.
- Claude API PricingAnthropic — current per-model, per-token rates; the source for the cost worked example above, checked this session.
Frequently asked questions
Does this stay inside NetSuite governance limits?
Yes. Map/Reduce gives each map() and reduce() invocation its own governance budget instead of sharing one scheduled-script budget across the whole batch, and the framework yields and resumes automatically as a job nears that limit — no code required for the yield itself, though the script should still call getRemainingUsage() before an expensive step.
What does the AI actually do?
It compares invoice lines to PO lines and returns a confidence score per line, not a final decision. Every response is validated against a fixed JSON schema before anything is written to NetSuite, and only high-confidence matches are auto-approved.
How are low-confidence matches handled?
They write a custom exception record with the AI’s proposed match, confidence, and stated reason, and route to a human AP queue. The exception record — not the Vendor Bill — carries the review status, so the loop can be tracked and alerted on independent of the transaction.
What happens if the AI API call fails or times out?
The bill’s status stays PENDING and it is retried on the next scheduled run. A lock timestamp, not a boolean flag, prevents a rerunning script from grabbing the same bill twice while a prior call is still in flight, and a separate scheduled check clears stale locks so a genuinely failed call is not stuck forever.
Which AI model should this pattern use?
A small, fast model is enough — this is structured extraction against a fixed JSON schema, not open-ended reasoning. As of August 2026, Anthropic’s published pricing puts Claude Haiku 4.5 at $1 per million input tokens and $5 per million output tokens, the appropriate tier for a per-line comparison task run thousands of times an hour. Reserve a larger model for lines the schema itself flags as ambiguous.

Leave a Reply