Skip to content

Part 7 of 7 · Gift Aid claimer series ~7 min read

Engineering reference: the gift aid claimer architecture

The same system with the service names filled in: what reads a declaration, what routes a donation, what builds the claim, 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 gift aid claimer drawn with AWS service namesThree boxes across the top sit outside the AWS account. Declarations as scans, web posts and confirmations. Donation exports as CSV from the bank and the gateway. And Scheme rules: rates and limits, dated. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge handling uploads and the period close. Four Lambda functions covering declare, route, claim and evidence. And three DynamoDB tables holding donors, donations and claims. A note says eu-west-2, one account, declarations arrive all year, donations load nightly, and the claim runs once per accounting period and freezes its inputs.AWS ACCOUNTDeclarationsscans, web posts,confirmationsDonation exportsCSV from the bankand the gatewayScheme rulesrates, limits,datedS3 + EventBridgeuploads and theperiod closeLambda x4declare, route,claim, evidenceDynamoDB x3donors, donations,claimsingroundsouteu-west-2. One account. Declarations arrive all year; donations load nightly; the claim runs once per accounting period and freezes its inputs.
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
  • Security & identity

Region and account

  • Region: eu-west-2. London, because the donor addresses in a declaration are personal data belonging to UK residents and there is no reason to move them. Bedrock model availability is checked here rather than assumed; if the model needed is not present, the extraction step is the only part that would move.
  • 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
ga-declareS3 put, declarations prefixReads a declaration into a donor and a dated coverage window60s / 1024MB
ga-routeNightly, after the donation loadMatches each donation to a donor and labels it with a scheme120s / 1024MB
ga-claimEventBridge, on period closeFreezes the routing, applies the caps, produces the schedule300s / 2048MB
ga-evidenceStep after claimWrites declarations, frozen donations and reasoning 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
ga-declare-rolebedrock:InvokeModel, s3:GetObject, dynamodb:PutItemOne model id; the declarations prefix; donors
ga-route-roles3:GetObject, dynamodb:Query, dynamodb:UpdateItemThe donations prefix; donors read; donations write
ga-claim-roledynamodb:Query, dynamodb:PutItemdonors and donations read; claims write
ga-evidence-roledynamodb:Query, s3:PutObject, ses:SendEmailAll three tables read; the evidence 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: donors

PK   charity_id#donor_id S
SK   ’decl#’ + effective S   one item per declaration, newest last
     effective_from      S   the date coverage begins
     covers_past         BOOL whether the wording reaches back four years
     cancelled_on        S   null until it is not
     home_address        S   HMRC matches on this; no address, no claim
     source              S   paper | web | verbal
     confirmed_taxpayer  BOOL the donor’s own statement
     evidence_key        S   the document itself, not a flag

A donor is a LIST of declarations, not a boolean. Coverage on a given
date is a question asked of the list; a single gift_aid flag answers a
different question and is the most common cause of an over-claim.

Table: donations

PK   charity_id#period   S
SK   donation_id         S
     donated_on          S   the date every decision turns on
     amount              N
     method              S   bank | card | cash | contactless
     donor_id            S   null for a bucket collection
     route               S   gift_aid | small_donations | none
     reason              S   set only when route is none
     claim_id            S   set when a claim freezes this row

reason takes six values and they lead to different actions: no
declaration, declaration started later, donor cancelled, not a UK
taxpayer, over the small-donation limit, no donor identified.
Collapsing them into ’not claimable’ throws away the only actionable
output this system produces.

Table: claims

PK   charity_id          S
SK   period#claim_id     S   claims are never overwritten
     submitted_on        S
     gift_aid_donations  N
     gift_aid_value      N
     small_donations     N
     small_value         N
     binding_cap         S   allowance | matching | none
     excluded_count      N   reported as prominently as the claim
     excluded_value      N
     evidence_key        S

binding_cap records WHICH limit bound rather than that one did. The
annual allowance and the matching rule have completely different fixes,
and a claim that just says ’capped’ cannot tell a charity which one it
is looking at.

Inbound and outbound

  • Declarations arrive by upload or mailbox and both land in the same function. A photograph of a signed form and a web submission are the same document here.
  • Donation exports are parsed, not read by a model. They are fixed-header CSV, they are the largest files in the system, and a model would be slower and less accurate than a parser.
  • An uncertain donor match goes to a human. A donation claimed against somebody else’s declaration is the error that becomes a repayment, and asking costs one line on a list.
  • The claim waits for the period close, and freezes its inputs before it sums anything. A declaration arriving the next morning belongs to the next run.

The model call

  • One call per declaration. Nothing per donation, per claim or per donor lookup. The bill does not move when donations do.
  • A mid-tier model. A declaration has five fields and the only ambiguous one is the date; a frontier model buys nothing here.
  • Dates come back as strings in a stated format, never parsed by the model into a guess. A UK form writes 03/02 and the difference between February and March is a claim.
  • Scope is extracted, not assumed. Whether the wording covers the previous four years is a field on the record, because it is the single largest recoverable amount in most charities.
  • No model touches routing or arithmetic. Both have to be re-runnable and explainable years later, and both are a query and a multiplication.

Things worth knowing before you build it

  • Store the declaration’s effective date, not a boolean. Coverage is a window with a beginning, and a donation is claimable or not according to where its own date falls.
  • Read whether the wording covers the previous four years. Most standard forms do, most charities have no field for it, and re-running the routing over that period is usually the largest single win available.
  • Keep the document, not a note that one exists. A ticked box builds a claim and does not defend one, and the difference is invisible until somebody asks.
  • Record which cap bound the small-donations claim. The annual allowance and the matching rule against ordinary Gift Aid have different fixes.
  • Count the retention from the end of the accounting period, not from the donation. A gift in the first month of a year is held nearly seven years.
  • Report the excluded donations and their value beside the claim. It is the only number in the pack that a phone call can change.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts