Skip to content

Part 7 of 7 · Late payment interest claimer series ~7 min read

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

The late payment interest claimer drawn with AWS service namesThree boxes across the top sit outside the AWS account. Ledger exports carrying invoices and payments, nightly. Agreements as contracts, purchase orders and accepted quotes. And Base rates, at two reference dates a year. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge handling imports and the nightly accrual. Four Lambda functions covering terms, due, accrue and pack. And three DynamoDB tables holding customers, debts and segments. A note says eu-west-2, one account, agreements are read on change, the ledger lands nightly, interest is a multiplication rather than a recalculation, and nothing is ever sent to a customer without a person pressing something.AWS ACCOUNTLedger exportsinvoices and payments,nightlyAgreementscontracts, POs,accepted quotesBase ratestwo reference datesa yearS3 + EventBridgeimports and thenightly accrualLambda x4terms, due, accrue,packDynamoDB x3customers, debts,segmentsingroundsouteu-west-2. One account. Agreements are read on change; the ledger lands nightly; interest is a multiplication rather than a recalculation; nothing is ever sent to a customer without a person pressing something.
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
  • 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 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
lpi-termsS3 put, agreements prefixReads payment terms, verification period and any contractual remedy60s / 1024MB
lpi-dueNightly, after the ledger importEstablishes a due date per invoice and records which basis produced it180s / 1024MB
lpi-accrueNightly, after lpi-dueOpens, closes and splits interest segments on payments and credits300s / 1024MB
lpi-packOn request, per customerAssembles the working, the three lines and the evidence into one object300s / 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
lpi-terms-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the agreements prefix; customers
lpi-due-roles3:GetObject, dynamodb:Query, dynamodb:UpdateItemThe ledger prefix; customers read; debts write
lpi-accrue-roledynamodb:Query, dynamodb:PutItemdebts read; segments append. No update, no delete
lpi-pack-roledynamodb:Query, s3:PutObject, ses:SendEmailAll 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