Skip to content

Part 7 of 7 · Payout reconciler series ~7 min read

Engineering reference: the payout reconciler architecture

The same system with the service names filled in: what fetches and parses the reports, what types the lines, what keeps the clearing accounts, and what matches the bank and closes the month.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 6 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 3 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 payout reconciler drawn with AWS service namesThree boxes across the top sit outside the AWS account. Provider reports, from APIs and uploaded exports. Bank feed, deposits and debits, daily. And Accounts, where journals are imported by a person. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge holding raw reports and running the schedules. Six Lambda functions covering ingest, classify, confirm, post, match and close. And three DynamoDB tables holding lines, clearing and decisions. A note says eu-west-2, one account, reports are appended daily, lines post to clearing per provider, and the close posts nothing itself.AWS ACCOUNTProvider reportsAPIs and uploadedexportsBank feeddeposits and debits,dailyAccountsjournals, importedby a personS3 + EventBridgeraw reports andthe schedulesLambda x6ingest, classify, confirm,post, match, closeDynamoDB x3lines, clearing,decisionsingroundsouteu-west-2, one account. Reports are appended daily, lines post to clearing per provider, and the close posts nothing itself.
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
  • Networking
  • Analytics

Region and account

  • Region: eu-west-2. London, because the business, its bank and its VAT return are all UK ones and nothing in this design receives email. Bedrock model availability is checked here rather than assumed; the line classifier is the only step that would have to move, and every other step is parsing and arithmetic.
  • 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
payr-ingestEventBridge daily; S3 put, uploads prefixFetches or reads each provider’s report, detects its layout, appends settlement lines300s / 1024MB
payr-classifySQS, one message per unmapped line or PDFTypes by remembered wording, then the model; checks PDF lines against the printed total60s / 1024MB
payr-confirmFunction URL, signed link from the packRecords a person’s type for a wording, so it is reused from then on30s / 256MB
payr-postAfter ingest, or classify where neededProves lines sum to the payout, posts to clearing, compares balances120s / 1024MB
payr-matchS3 put, bank feed prefixMatches bank lines to expected payouts, sets of payouts and provider debits120s / 1024MB
payr-closeEventBridge, monthly, after the last report landsCuts off on the sale date, writes the gross journal and the reconciliation pack300s / 2048MB

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
payr-ingest-rolesecretsmanager:GetSecretValue, s3:GetObject, s3:PutObject, dynamodb:BatchWriteItem, sqs:SendMessageThe four provider secrets, each by arn; the reports prefix; lines; the classify queue
payr-classify-rolebedrock:InvokeModel, dynamodb:GetItem, dynamodb:UpdateItem, s3:GetObjectOne model id; decisions read only; lines update; the statements prefix
payr-confirm-roledynamodb:PutItem, dynamodb:UpdateItemdecisions write; lines update. No Bedrock, no S3, no bank data
payr-post-roledynamodb:Query, dynamodb:PutItemlines read; clearing write with attribute_not_exists
payr-match-roles3:GetObject, dynamodb:Query, dynamodb:UpdateItemThe bank feed prefix; clearing only. No access to lines
payr-close-roledynamodb:Query, s3:PutObject, ses:SendEmaillines and clearing read; the journals prefix write-once; one verified identity

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: lines

PK   provider#account      S   e.g. checkout#gbp-main
SK   occurred_at#line_id   S   UK time; the provider’s own line id
     type                  S   sale | refund | dispute | dispute_reversal |
                               processing_fee | other_charge | reserve_held |
                               reserve_released | payout | correction | marketing
     amount                N   pence; positive raises what the provider owes you
     payout_id             S   set once the provider assigns the line to one
     typed_by              S   code | wording | model | person
     confirmed             BOOL false only where typed_by is model
     vat_shown             M   {treatment, amount} as the provider’s invoice states
     source_key            S   the raw report or PDF, and the row within it

amount has one sign convention for every provider: positive means the
provider owes you more. Each parser converts on the way in, which is the
only place four sign conventions are allowed to exist. A line stored with
the provider’s own sign is exactly how a refund ends up added to sales.

Table: clearing

PK   provider#account      S
SK   kind#date#id          S   payout#2026-04-02#po_4471 | check#2026-03-31
  payout items:
     amount                N   pence; negative for a provider debit
     lines_total           N   the sum of its lines when it was posted
     line_count            N
     bank_txn              L   one bank line, or several for a set
     state                 S   expected | matched | overdue | unexplained
  check items:
     ours                  N   clearing balance from lines and payouts
     theirs                N   the provider’s reported balance, same instant
     in_transit, reserve   N   what the balance is made of
     drift_note            S   required whenever ours and theirs differ

lines_total is stored on the payout item rather than recomputed. payr-post
refuses to write a payout whose lines do not equal it, so every payout in
the table carries its own proof that it balanced on the day it was
posted — even after a late line or a re-import changes the lines table.

Table: decisions

PK   provider              S
SK   wording               S   normalised: lower case, dates, digits and
                               references removed
     type                  S   one of the eleven
     confirmed_by          S   a person, never the model
     confirmed_at          S
     remember              BOOL false where the wording is too vague to reuse
     times_applied         N
     example_line          S   the line the person was looking at

wording is normalised before it becomes a key, because providers put the
month, a reference or an amount inside otherwise identical text.
Terminal rental Aug and Terminal rental Sep have to be one decision; a key
built from the raw text makes them twelve a year and twelve model calls.

Inbound and outbound

  • Reports are fetched, not received. Where a provider has a reporting API the ingest pulls what is new since its last cursor; where it does not, a person uploads the export to a prefix and the same parser runs. Nothing arrives by email, which is why the region can stay in London.
  • Layouts are detected from the header. A header the parser has never seen stops that report rather than being guessed at, and every row it could not place is counted and shown in the pack.
  • Lines are appended, keyed on the provider’s own id. Re-importing an overlapping date range writes nothing new. Provider reports only go back so far, so the stored lines are the record and are never rebuilt from a live export.
  • Nothing is posted to the accounts automatically. The close writes a journal file and a pack and emails one person. Importing the journal changes reported sales, and that is a decision somebody should take having read the pack.

The model call

  • One call per unmapped line or PDF statement. Nothing per sale, per payout or per month. Everything with a known type code is mapped in configuration before the queue.
  • A small model. Picking one of eleven types for a sentence of free text is classification. The prompt carries the provider’s name and the sign of the amount, which settles most cases on its own.
  • A closed list, with null. The response schema allows the eleven types or null. An answer outside the list is treated as null, and null goes to a person rather than to a default type.
  • Amounts from a PDF must sum to the printed total. They are the only figures in the system a model produces, so code checks them before any line is accepted, and a mismatch rejects the whole statement rather than the odd line.
  • VAT is transcribed, never inferred. The model copies the VAT treatment and amount printed against each PDF charge, or returns null. It is not asked what the treatment ought to be.

Things worth knowing before you build it

  • Post the payout as a transfer from clearing to the bank, never as a sale. Every other rule in the system follows from that one.
  • Give every provider, and every currency or account within a provider, its own clearing account. A combined account can balance while every part of it is wrong.
  • Read the report whose running total is the provider’s balance, not the one grouped by payout. The second lists activity late, and the balance comparison drifts for days.
  • Store amounts in pence with one sign convention, converted by each parser on the way in. The provider’s own sign is how a refund ends up added to sales.
  • Convert provider timestamps to UK time before cutting off a month. A provider reporting in UTC puts an hour of sales on the wrong side of every month end during British Summer Time.
  • Expect negative payouts. When refunds and disputes beat sales, some providers debit the bank, and a matcher that only looks for credits leaves that line unexplained for ever.

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

All posts