Back to Blog
Printed tax forms, a calculator, a pen, and an envelope arranged on a desk

AI Invoice Processing With Duplicate Payment Controls

8 min read

AI invoice processing goes wrong when a clean extraction is mistaken for a safe accounting action. An OCR service returns an invoice number, supplier, total, tax and line items, and one value can still be wrong. The same document can also arrive twice by email, upload or retry. Separate extraction from validation, approval and posting. Keep the source evidence, check the financial relationships, search for duplicates before review, bind approval to the reviewed version, and make the final write idempotent.

Extraction is not approval

Parsers solve a document interpretation problem. Microsoft's invoice model, Google's Invoice Parser and Amazon Textract expense analysis all return broad sets of fields, line items and confidence values. Those outputs are evidence. A high confidence score says the extractor matched a field strongly. It does not say that the supplier exists in your vendor master, the arithmetic balances, the invoice is new or the purchase was approved. Treat the response as an untrusted proposal, store it beside the original file, and run deterministic checks before a person or system accepts it.

The control a duplicate check cannot give you

Duplicate detection protects against paying the same invoice twice. It does nothing against paying a genuine-looking invoice to the wrong account, and an extractor makes that attack easy to automate because it reads bank details off an attacker-controlled PDF without complaint. The FBI's 2024 Internet Crime Report counts 21,442 business email compromise complaints with adjusted losses of $2,770,151,146 for the year. Add a payee check to the pipeline. Compare the bank account on every invoice with the one in the vendor master, and treat any difference as an exception that routes to a person who confirms through a contact method already on file, never through the details printed on the invoice. A first invoice from a new supplier gets the same treatment. This check passes every duplicate test, because the invoice is new, and it is the one that matters most when the number is large.

Define a canonical invoice record

Map each provider's response into one internal record that keeps normalized values and source evidence apart. Store the source document ID, file hash, channel and received time. Store the supplier name as extracted, the resolved vendor ID and the resolution status. Keep the invoice number as extracted, a normalized comparison form and its confidence, plus date, currency, subtotal, tax, total and amount due, and line items with page and bounding region. Add the parser name and version, the raw payload location, per-field confidence, validation findings, duplicate candidates, review status, a record version and a posting status. If the invoice says INV 0042-A, keep that exact string for display and audit. A normalized INV0042A is for comparison only.

The invoice number is a business identifier assigned by the seller; the Peppol invoice identifier rule says so. Two suppliers can legitimately use the same number, so supplier identity is part of every comparison.

Provider idempotency keys are short-lived, and the lifetime differs a lot by vendor. Stripe documents roughly 24 hours. Xero's idempotency guide says keys "are stored for 6 minutes from the time of the first call", after which the same key "is processed as a new key". A public proof-of-concept for a CRM-to-Xero sync draws the practical conclusion: a six-minute key is a dedupe for fast retries, not a durable replay, so a worker that crashes and resumes after the window needs a second guard. Its answer is to plant values that Xero itself enforces or that can be looked up before resending, such as a unique invoice number. Do the same for payables: derive the supplier invoice number from your own duplicate fingerprint, look it up before any retry that is older than the key window, and treat a retry that cannot be matched as a case for a person.

Validate before matching duplicates

Bad normalization produces bad matches, so validate first. Require the fields your posting contract needs (resolved supplier, number, date, currency, total) and route gaps to correction instead of letting the model fill them. Check line amounts against the subtotal, subtotal plus tax against the total, and amount due against any payments or credits on the document, using currency-specific decimal rules and a documented rounding tolerance. Validate each line for negative quantities, unsupported currencies, impossible dates and inconsistent tax. Set confidence thresholds by field and consequence: a weak description may be fine if it does not affect coding, while a weak invoice number or total blocks automatic posting because those fields drive duplicate detection and value.

Detect exact and near duplicates

Produce candidates with reasons. Never delete or reject on one fuzzy score. Layer the checks: the source file hash catches the same bytes arriving by another channel; resolved supplier plus normalized invoice number is the strongest business identity; supplier, total, currency and date within a narrow window catches number extraction errors and rescans; and text or image fingerprints serve only as supporting evidence, since recurring templates and amounts cause false positives. Keep a unique constraint on the strongest accepted identity, for example tenant, supplier, normalized number and document type. The application check gives the reviewer context, and the constraint closes the race where two workers both see no existing record.

A candidate can be the same invoice, a legitimate recurring invoice, a credit or correction, or an unrelated bill with similar values. Show the reviewer the existing record, the fields that matched, source previews and its lifecycle state, because a rejected invoice differs from one already posted and paid.

Route exceptions with typed reasons

Send only invoices with a reason to the queue: a missing field, an unresolved or ambiguous supplier, a matching identity, failed reconciliation, a consequential field under threshold, a bank account that differs from the vendor master, a failed purchase order or receiving check, or an uncertain posting after a timeout. Show the source region next to each extracted value, record edits as patches with who, old value, new value, reason and time, and bump the record version after any edit or new duplicate finding. Approval binds to that version. If the supplier, number, total, currency, lines, duplicate disposition, bank account or supporting purchase order changes after review, the approval is void.

Make posting safe to resume

Posting is its own state transition. The finance system may accept the request and your worker may time out before storing the response. Create one posting command per approved invoice version, with a stable ID and a state (pending, submitted, confirmed, failed, reconciliation_required). Derive the idempotency key from the invoice identity, approved version, target system and action. Stripe's idempotent requests documentation is a useful model, and it also shows traps that a generic "use an idempotency key" instruction hides. For its v1 API, Stripe saves the status code and body of the first request made with a key, including 500 errors, and returns that saved response on a retry. It may remove keys once they are at least 24 hours old and treats a reused key after pruning as a new request. It also errors when the same key arrives with different parameters.

Three consequences follow for payables. A reconciliation job that runs after the provider's retention window cannot depend on replay, so look up the posting by your external reference. If the first attempt failed for a fixable reason and the response was saved, a retry under the same key will return that failure, so a repaired command needs a new command version and a new key. And a key reused with an edited payload should fail visibly, which is the behavior you want after an approval is voided. For an accounting API without native idempotency, put the command ID in a unique reference field and query it before retrying. A timeout never means failure. If the target cannot say whether the operation happened, stop retrying and send the item to reconciliation.

def post_approved_invoice(invoice_id, approved_version, command_id):
    invoice = store.load(invoice_id)
    policy.require_approved_version(invoice, approved_version)
    policy.require_no_open_duplicate(invoice)
    policy.require_payee_matches_vendor_master(invoice)
    policy.require_balanced_amounts(invoice)
    prior = commands.find(command_id)
    if prior and prior.status == "confirmed":
        return prior.result
    commands.mark_submitted(command_id, invoice_id, approved_version)
    try:
        result = accounting.create_voucher(
            invoice=invoice.to_posting_payload(),
            idempotency_key=command_id,
        )
    except UnknownRemoteOutcome:
        commands.mark_reconciliation_required(command_id)
        raise
    commands.mark_confirmed(command_id, result.external_id)
    return result

The transaction boundary depends on your accounting system. What does not change is a stable command identity, current approval checks, a fresh duplicate and payee check immediately before posting, and an explicit state for an uncertain result.

Corrections, credits and vendor merges

When a supplier corrects an invoice, create a new version or a linked correction with its own review and posting decision, and never reuse the posted record's identity. Decide how credit notes, cancellations and replacements are treated: similar supplier, number and amount may be a duplicate, while a negative amount or document type may be a valid reversal, so keep links such as corrects_invoice_id and credits_invoice_id. When a reviewer merges two vendor records, rerun duplicate matching on unposted invoices under the surviving vendor, since something that looked unique under an unresolved supplier may now collide.

Test the whole workflow

Build fixtures from clear digital invoices, scans, rotated pages, repeated templates, multi-page line items, credits, tax variations and low-quality fields. Then run the negative paths. Upload identical bytes twice through different channels and expect one posting. Change punctuation in the number and expect an explained normalized match. Reuse a number across two suppliers and expect no collision. Change the total after approval and expect the old approval to fail. Change the bank account after approval and expect the same. Make two workers pass the application check at once and expect the constraint to reject one. Time out after the downstream system accepts the command and expect a lookup, not a new command. Pair a low confidence total with a high confidence description and expect only the total to block. Correct a vendor mapping and expect duplicate detection to run again.

Track correction rates by field, exception reasons, reviewer changes, duplicate dispositions, posting retries and reconciliation outcomes. Straight-through rate alone is the wrong target, since a workflow that posts more invoices by weakening controls has not improved. Start with one supplier group and one accounting action, write down the record, checks, duplicate layers, invalidation rules and command identity, and run the negative tests before automatic posting. Before rollout, prove that every posted invoice maps to one approved version and one confirmed command.

References


About Fire In Belly: Independent senior engineering from Tallinn, Estonia. We design and build AI workflow automation with the validation gates, duplicate controls, and idempotent posting described above, at published fixed prices. Schedule a call to discuss your next project.