Engineering reference: the rebate claim tracker 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, versioned rules, and the one 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
- Compute
- Storage
- Database
- People
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
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 |
|---|---|---|---|
rb-extract | S3 upload of an agreement or amendment | One model call; writes a new dated rule version with the source page references | 60s / 1024 MB |
rb-accrue | EventBridge, nightly, after the ledger feed | Matches lines to rules, applies exclusions, moves accruals, recomputes tier distance | 300s / 1024 MB |
rb-alert | EventBridge, weekly | Raises tier-proximity alerts with weeks remaining and the value of the uplift | 60s / 512 MB |
rb-claim | EventBridge, daily; API on submit | Fires the two window reminders, assembles the claim and its line evidence, chases to received | 120s / 1024 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
| Role | Allowed | On |
|---|---|---|
rb-extract-role | bedrock:InvokeModel, s3:GetObject, dynamodb:PutItem | One model id; the agreements prefix; rules |
rb-accrue-role | dynamodb:Query, dynamodb:UpdateItem | Rules read; accruals write |
rb-alert-role | dynamodb:Query, ses:SendEmail | Accruals; one verified identity |
rb-claim-role | dynamodb:UpdateItem, s3:PutObject, ses:SendEmail | Accruals; the claims prefix; 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: rules
PK supplier_id S
SK effective_from#v S versions, never edited in place
basis S spend | units | growth
tiers L [{threshold, rate}], ascending
retrospective BOOL null until somebody confirms it
period S calendar | financial | rolling-12
period_end S
window_days N days after period end to claim
exclusions L freight | deal | range: | unpaid
claim_format S portal | template | email
source_key S the original PDF in S3
source_pages L where each field was found, for disputes
retrospective is deliberately nullable. A guess here is wrong half
the time and changes the accrual by a factor of four.
Table: accruals
PK supplier_id#period S
SK ’#state’ | line#id S one state item, one item per qualifying line
qualifying_total N state item; after exclusions
tier_achieved N index into the rule’s tiers
earned N at the achieved tier only
next_tier_gap N what the alert is built from
claimed_at S
claimed_amount N
received_at S the number that actually counts
received_amount N
window_closes S
missed_amount N set when a window closes unclaimed
missed_reason S required, even if it is ’nobody picked it up’
earned, claimed_amount and received_amount are three separate fields
on purpose. Collapsing them is how a claim stops being followed up.
Inbound and outbound
- Purchase lines arrive nightly as a feed from the finance system. This is not a real-time system and pretending otherwise adds a integration for no decision.
- Agreements arrive by upload, which is the only manual step and the only one worth keeping manual.
- Alerts go to the buyer, claims and windows go to finance. They are different people with different deadlines and merging the mail loses both.
- The miss log is never purged. A running total of unclaimed rebate is the only thing that reliably gets this work resourced.
The model call
- One call per agreement document. Five fields out of three pages of prose that no two suppliers write the same way.
- A small, fast model. This is extraction from a short document; a frontier model returns the same five fields for eight times the money.
- Nulls are required, not tolerated. A missing retrospective flag becomes a question to a human, never an inference.
- Page references come back with the fields, so a disputed term can be checked against the original sentence in seconds.
- Nothing else calls a model. Matching lines, applying exclusions and computing tiers is arithmetic, and arithmetic that can be checked by hand is worth more here than anything a model could add.
Things worth knowing before you build it
- Version rules by effective date and never edit one. A mid-year amendment means a single period evaluated under two rules, and an in-place edit silently loses half the year.
- Apply exclusions at line level from day one. Accruing on gross spend puts you ten thousand pounds apart from the supplier at claim time and makes every claim a negotiation.
- Accrue only the tier already achieved. Optimistic accrual inflates margin all year and reverses in one month that then looks like a trading problem.
- Send the tier alert six to eight weeks out. Two weeks out it produces a panic buy at the wrong price, which is worse than missing the tier.
- Track received, not claimed. A submitted claim behaves exactly like an unpaid invoice and dies the same way, quietly, in somebody else’s queue.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts