Skip to content

Part 7 of 7 · Invoice dispute triager series ~7 min read

Engineering reference: the invoice dispute triager architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the inbound mail path, and the single model call.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 4 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The invoice dispute triager drawn with AWS service namesThree boxes across the top outside the AWS account. SES inbound, receiving mail sent to the accounts address. Your records, meaning the ledger API and the documents held in S3. And SES outbound, carrying the decision screens and the reply drafts. Inside the account, three groups. S3 holding the raw mail and SQS carrying one dispute queue. Four Lambda functions named recognise, classify, gather and record. And two DynamoDB tables named disputes and invoices. A note gives the region as us-east-1, one account, and states that the disputed flag is the only thing this system writes outside itself.AWS ACCOUNTSES inboundthe accounts addressYour recordsledger API, S3 documentsSES outbounddecisions, draftsS3 + SQSraw mail,one dispute queueLambda x4recognise, classify,gather, recordDynamoDB x2disputes, invoicesingroundsoutus-east-1. One account. The disputed flag is the only thing written outside this system.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Storage
  • Database
  • App integration

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
id-recogniseS3 ObjectCreatedHeader filters, remittance detection, invoice match, pauses the chaser10s / 512 MB
id-classifySQS dispute queueOne Bedrock call into a reason, a line and a confidence20s / 1024 MB
id-gatherSQS classified queueFetches the evidence for that reason and writes the one-liner30s / 1024 MB
id-recordFunction URLHandles the signed decision links; resumes chasing on the right schedule10s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
id-recognise-roles3:GetObject, dynamodb:UpdateItemThe mail prefix; the invoices table disputed flag only
id-classify-rolebedrock:InvokeModel, sqs:SendMessageOne model arn; the classified queue
id-gather-roles3:GetObject, ses:SendRawEmailThe documents prefix; one verified identity
id-record-roledynamodb:UpdateItem, secretsmanager:GetSecretValueDisputes and invoices; the signing key only

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: disputes

PK   dispute_id        S   dsp_2026_07_14_9c2b
     invoice           S   4412
     customer          S   acme-ltd
     state             S   open | gathered | decided
     reason            S   quantity | price | duplicate | not_ordered |
                           wrong_entity | not_invoice | unknown
     reason_actual     S   set when a person decides
     confidence        N   0.91
     line              S   which invoice line, where determinable
     evidence          L   [{kind, s3_key, one_liner}]
     evidence_gap      S   none | no_signed_pod | no_quote_on_file
     outcome           S   upheld | not_upheld | partial | withdrawn
     opened / closed   S   ISO timestamps
     ttl               N   epoch, +7 years

GSI  customer-index      PK customer, SK opened   — the per-customer view

Table: invoices

PK   invoice           S   4412
     customer          S   acme-ltd
     total             N   3240.00
     lines             L   [{description, qty, unit_price}]
     disputed          BOOL true      — what the chaser reads
     dispute_id        S   dsp_2026_07_14_9c2b
     chase_from        S   2026-07-21    — the grace period after a decision

This table is a projection of your real ledger, not a replacement for it.
The only field this system writes is `disputed` and `chase_from`.

Inbound and outbound

  • An SES receipt rule set on the accounts address writes the whole message to S3 with attachments. The S3 event fires id-recognise directly.
  • Header filters run before anything else. Auto-Submitted, X-Autoreply and a bulk precedence are all discarded without a model call.
  • Threading uses In-Reply-To and References to find the invoice, which is more reliable than parsing a number out of a body.
  • Decision links are signed, scoped to one dispute, single-use, and expire after thirty days.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock. The task is classifying a short customer message into one of six labels.
  • Called once per recognised dispute, after every free structural filter has run, and never on an auto-reply or a remittance.
  • Output is a JSON schema with a reason, an optional second reason, an optional invoice line, and a confidence. Every field is nullable and a null reason is the hand-over-raw path.
  • Grounded with the invoice lines, so the model can identify which line is being disputed rather than only what kind of complaint it is.
  • Nothing about the evidence touches a model. Comparing a claimed quantity with a signed delivery note is a lookup and a comparison, and both should be code.

Things worth knowing before you build it

  • Pause the chaser before classifying, not after. It is the only irreversible step in the sequence and it has to happen within seconds.
  • Match invoice numbers within the customer, not globally. Customer-scoped matching removes almost every false positive at no cost.
  • Set the confidence floor high. A wrong class attaches the wrong paperwork and costs two round trips; an honest unknown costs one.
  • Keep `outcome` and `reason` as separate fields. Conflating them makes the monthly report unable to answer either question it exists to answer.
  • Do not resume chasing automatically on close. A reminder fired at somebody you have just told they were wrong is a worse outcome than a week of delay.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts