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
- 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
devand aprodstack 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
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
payr-ingest | EventBridge daily; S3 put, uploads prefix | Fetches or reads each provider’s report, detects its layout, appends settlement lines | 300s / 1024MB |
payr-classify | SQS, one message per unmapped line or PDF | Types by remembered wording, then the model; checks PDF lines against the printed total | 60s / 1024MB |
payr-confirm | Function URL, signed link from the pack | Records a person’s type for a wording, so it is reused from then on | 30s / 256MB |
payr-post | After ingest, or classify where needed | Proves lines sum to the payout, posts to clearing, compares balances | 120s / 1024MB |
payr-match | S3 put, bank feed prefix | Matches bank lines to expected payouts, sets of payouts and provider debits | 120s / 1024MB |
payr-close | EventBridge, monthly, after the last report lands | Cuts off on the sale date, writes the gross journal and the reconciliation pack | 300s / 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
| Role | Allowed | On |
|---|---|---|
payr-ingest-role | secretsmanager:GetSecretValue, s3:GetObject, s3:PutObject, dynamodb:BatchWriteItem, sqs:SendMessage | The four provider secrets, each by arn; the reports prefix; lines; the classify queue |
payr-classify-role | bedrock:InvokeModel, dynamodb:GetItem, dynamodb:UpdateItem, s3:GetObject | One model id; decisions read only; lines update; the statements prefix |
payr-confirm-role | dynamodb:PutItem, dynamodb:UpdateItem | decisions write; lines update. No Bedrock, no S3, no bank data |
payr-post-role | dynamodb:Query, dynamodb:PutItem | lines read; clearing write with attribute_not_exists |
payr-match-role | s3:GetObject, dynamodb:Query, dynamodb:UpdateItem | The bank feed prefix; clearing only. No access to lines |
payr-close-role | dynamodb:Query, s3:PutObject, ses:SendEmail | lines 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