Engineering reference: the late payment interest claimer architecture
The same system with the service names filled in: what reads an agreement, what establishes a due date, what accrues interest nightly, and where the evidence sits.
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.
- 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
- Analytics
Region and account
- Region:
eu-west-2. London, because this is UK commercial debt and the statutory scheme it implements is a UK one, so there is no case for the data being anywhere else. Bedrock model availability is checked here rather than assumed; the agreement read is the only step that would move, and it is the only step that is not 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 |
|---|---|---|---|
lpi-terms | S3 put, agreements prefix | Reads payment terms, verification period and any contractual remedy | 60s / 1024MB |
lpi-due | Nightly, after the ledger import | Establishes a due date per invoice and records which basis produced it | 180s / 1024MB |
lpi-accrue | Nightly, after lpi-due | Opens, closes and splits interest segments on payments and credits | 300s / 1024MB |
lpi-pack | On request, per customer | Assembles the working, the three lines and the evidence into one object | 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 |
|---|---|---|
lpi-terms-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the agreements prefix; customers |
lpi-due-role | s3:GetObject, dynamodb:Query, dynamodb:UpdateItem | The ledger prefix; customers read; debts write |
lpi-accrue-role | dynamodb:Query, dynamodb:PutItem | debts read; segments append. No update, no delete |
lpi-pack-role | dynamodb:Query, s3:PutObject, ses:SendEmail | All three tables read; the evidence prefix write-once; one verified identity, internal recipients 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: customers
PK org_id#customer_id S
SK ’terms#’ + effective S one item per agreement version
basis S agreed | invoice_terms | statutory_default
days N the agreed period, null on the default
verification_days N an acceptance window, if there is one
contract_remedy M {rate, clause, substantial: BOOL|null}
challengeable BOOL set where the agreed period exceeds 60 days
evidence_key S the agreement, and the page the clause is on
contract_remedy.substantial is deliberately three-valued. A clause that
exists is not the same as a clause that displaces the statutory right,
and null means a human has not yet decided. The accrual job treats null
as ’calculate both and flag’, never as ’assume statutory’.
Table: debts
PK org_id#customer_id S
SK invoice_id S
performance_date S delivered or completed
notice_date S when they received the invoice
notice_evidence S send log id, portal receipt, signature
due_date S
due_basis S which of the three routes produced it
late_from S the day after due_date
reference_date S 31-12 or 30-06, whichever governed
base_rate N the figure on that date, stored not looked up
statutory_rate N base_rate + 8
fixed_sum N banded by the original invoice amount
recovery_costs N only what exceeds fixed_sum, with an invoice
claim_expires_on S six years from when the entitlement arose
disposition S open | shown | waived | claimed | archived
base_rate is stored rather than joined to a rate table. A stored rate
makes last year’s report reproducible; a join makes it change quietly
the next time the table gains a row.
Table: segments
PK org_id#customer_id#invoice_id S
SK from_date S append-only
to_date S null while the segment is open
principal N the balance during this segment
daily_amount N principal * rate / 365, to the penny
ended_by S part_payment | credit_note | paid |
still_open
event_ref S the ledger line that ended it
Interest is the sum of segments, never a stored running total. A part
payment closes one segment and opens another, so the arithmetic for any
past date stays correct without a recalculation, and ’you paid half in
April’ is answered from the record rather than from a spreadsheet.
Inbound and outbound
- Ledger exports are parsed, not read by a model. They are fixed-header CSV from the accounting system, they are the largest files here, and a model would occasionally invent a payment date.
- Agreements are read once per version. A new contract is a new item under the same customer, never an edit, so a due date established last year still knows which terms produced it.
- Base rates are entered twice a year and stored on the debt. Two rows a year is not a data feed. Someone types it, someone else checks it, and every debt that goes late afterwards copies it.
- Nothing leaves the account addressed to a customer. SES sends internal packs to internal recipients. The letter, if there ever is one, is written by a person in whatever they already use.
The model call
- One call per agreement version. Nothing per invoice, per payment, per night or per pack. All of those are arithmetic.
- A mid-tier model. Four clauses out of a long document is an extraction task, not a reasoning one.
- Every term comes back with its clause, verbatim. A due date that is questioned is settled by reading the clause, and a field without a quotation cannot do that.
- Absent means null. No payment term found must produce null, which routes the invoice to the statutory default. A model that helpfully answers ’probably 30 days’ has produced the same number by a route that cannot be defended.
- No model touches the rate, the interest, the fixed sum or the dates. Every one of them has to reproduce exactly, years later, in front of somebody who disagrees.
Things worth knowing before you build it
- Store the notice date and the performance date separately. The default due date runs from the later of the two, and a system holding only the invoice date cannot compute it in either direction.
- Pin the rate when the debt goes late and store it. Looking it up at report time means last year’s figures change the next time the rate table gains a row.
- Do not compound. Statutory interest is simple, and adding it to the principal weakens the claim for the sake of a few pounds.
- The fixed sum is per invoice, once, and banded by the invoice amount. Charging it per chase or per month is the fastest way to have the whole claim dismissed.
- Claim recovery costs only above the fixed sum, and only where a third party invoiced you. Internal chasing time is what the fixed sum represents.
- Track the six-year expiry per invoice, and archive rather than delete when it passes. An entitlement that quietly disappears from the report is indistinguishable from one that was never calculated.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts