Skip to content

Part 7 of 7 · Utility bill watcher series ~7 min read

Engineering reference: the utility bill watcher 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 and what each is allowed to touch, the two tables, the scheduled sweeps, and the specific model.

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 utility bill watcher drawn with AWS service namesThree boxes across the top outside the AWS account. SES inbound, receiving supplier email with PDFs attached. The Meter list, read through the Google Sheets API read-only. And SES outbound, carrying the findings and the renewal messages. Inside the account, three groups. S3 holding the bill PDFs and SQS carrying one bill queue. Four Lambda functions named intake, read, compare and sweep. And two DynamoDB tables named bills and history. A note gives the region as us-east-1, one account, and notes two scheduled sweeps: one for overdue meters and one for approaching contract ends.AWS ACCOUNTSES inboundsupplier email + PDFMeter listSheets API, read-onlySES outboundfindings, renewalsS3 + SQSbill PDFs,one bill queueLambda x4intake, read,compare, sweepDynamoDB x2bills, historyingroundsoutus-east-1. One account. Two scheduled sweeps: overdue meters and contract ends.
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
  • App integration
  • Analytics

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
ub-intakeSES receipt rule + S3 ObjectCreatedStores the PDF, fingerprints the bill, enqueues one message10s / 512 MB
ub-readSQS bill queueTextract, then one Bedrock call into five fields plus the total check120s / 1024 MB
ub-compareSQS read queueMeter match, three comparisons, history write10s / 512 MB
ub-sweepEventBridge dailyOverdue meters and contract ends at T-90 and T-3030s / 512 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
ub-intake-roles3:PutObject, sqs:SendMessageThe bills prefix; the bill queue only
ub-read-roletextract:AnalyzeDocument, bedrock:InvokeModelThe bills prefix; one model arn
ub-compare-roledynamodb:PutItem/Query, secretsmanager:GetSecretValueBills and history; the Sheets credential only
ub-sweep-roledynamodb:Query, ses:SendEmailHistory, read; 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: bills

PK   fingerprint       S   sha256(supplier|meter|period_start|period_end)
     meter             S   1200034557
     supplier          S   as printed, normalised
     state             S   read | matched | unmatched | queried
     fields            M   {usage, unit_rate, standing, estimated, total}
     total_check       S   ok | mismatch
     pdf_key           S   s3://bills/2026/07/...
     ttl               N   epoch, +7 years

The fingerprint is the partition key, so a resent bill is rejected by the
conditional write rather than by a query that can race.

Table: history

PK   meter             S   1200034557
SK   period_end        S   2026-07-10
     days              N   30
     usage             N   4180.0
     usage_per_day     N   139.3
     unit_rate         N   0.241
     standing          N   0.48
     estimated         BOOL false
     baseline_ok       BOOL true

A year-on-year comparison is a single Query with a SK BETWEEN over the
same window last year, filtered to baseline_ok. No scan, ever.

Inbound and outbound

  • An SES receipt rule set on the bills domain writes the whole message to S3, attachments included. The S3 event is what fires the intake; there is no SNS hop.
  • Portal downloads go to a second S3 prefix that a person or a small sync drops files into. Both prefixes fire the same intake function.
  • Spam and virus verdicts are on the SES headers written into the object, and the intake drops anything failing either before parsing.
  • Suppression links in a finding message are signed, scoped to one meter and one finding shape, and expire after sixty days.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock. The task is picking five values out of a Textract layout, which is extraction.
  • Called once per new bill, after the duplicate test, never on a resend.
  • Output is a JSON schema with every field nullable. A null usage produces a question with the page attached; it never produces a zero.
  • Grounded with your meter numbers and their units, so the model matches to a known meter rather than reporting whatever string looks most like an identifier.
  • The total check is code. Recomputing usage times rate plus standing charge times days and comparing with the printed total is arithmetic, and arithmetic should not go near a model.

Things worth knowing before you build it

  • Store rates excluding tax and be strict about it. Comparing an inclusive rate against an exclusive contract produces a false alarm every single month.
  • Take the unit from your meter list, not from the bill. Gas appears in cubic metres, kWh and therms, and a silent unit change makes a year of history meaningless.
  • Normalise to per-day before comparing. Billing periods vary by several days and comparing raw totals generates constant noise.
  • Quarantine estimated readings from the baseline, and spread a catch-up bill across the days it covers before recomputing per-day figures.
  • Building the meter list is the hard part and it is not a software problem. Expect to find at least one supply nobody remembered, which is usually where the project pays for itself.

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

All posts