Skip to content

Part 7 of 7 · Rebate claim tracker series ~7 min read

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

The rebate claim tracker drawn with AWS service namesThree boxes across the top outside the AWS account. Agreements, arriving as PDFs and amendment letters. Purchase lines, from the finance system nightly. And Buyer and finance, receiving alerts, claims and the miss log. Inside the account, three groups. S3 for uploads and EventBridge for period clocks. Four Lambda functions named extract, accrue, alert and claim. And two DynamoDB tables named rules and accruals. A note gives the region as us-east-1, one account, and states that rules are versioned and never edited in place.AWS ACCOUNTAgreementsPDFs andamendment lettersPurchase linesfrom the financesystem, nightlyBuyer and financealerts, claims,the miss logS3 + EventBridgeuploads,period clocksLambda x4extract, accrue,alert, claimDynamoDB x2rules, accrualsingroundsoutus-east-1. One account. Rules are versioned and never edited in place.
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
  • 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 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
rb-extractS3 upload of an agreement or amendmentOne model call; writes a new dated rule version with the source page references60s / 1024 MB
rb-accrueEventBridge, nightly, after the ledger feedMatches lines to rules, applies exclusions, moves accruals, recomputes tier distance300s / 1024 MB
rb-alertEventBridge, weeklyRaises tier-proximity alerts with weeks remaining and the value of the uplift60s / 512 MB
rb-claimEventBridge, daily; API on submitFires the two window reminders, assembles the claim and its line evidence, chases to received120s / 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

RoleAllowedOn
rb-extract-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the agreements prefix; rules
rb-accrue-roledynamodb:Query, dynamodb:UpdateItemRules read; accruals write
rb-alert-roledynamodb:Query, ses:SendEmailAccruals; one verified identity
rb-claim-roledynamodb:UpdateItem, s3:PutObject, ses:SendEmailAccruals; 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