Skip to content

Part 7 of 7 · Cash drawer reconciler series ~7 min read

Engineering reference: the cash drawer reconciler 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 three tables, and the specific model.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 5 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 cash drawer reconciler drawn with AWS service namesThree boxes across the top outside the AWS account. Photo upload, covering the count sheet and any till printout. POS export, arriving either through SES inbound or as a direct S3 drop. And SES outbound, carrying the questions and the monthly summary. Inside the account, three groups. S3 holding the original photographs and SQS carrying one shift queue. Five Lambda functions named intake, read, explain, window and report. And three DynamoDB tables named shifts, windows and causes. A note gives the region as us-east-1, one account, with Secrets Manager holding the point-of-sale credential and the link-signing key.AWS ACCOUNTPhoto uploadcount sheet, printoutPOS exportSES inbound or S3 dropSES outboundquestions, summariesS3 + SQSoriginals,one shift queueLambda x5intake, read, explain,window, reportDynamoDB x3shifts, windows, causesingroundsoutus-east-1. One account. Secrets Manager holds the POS credential and the signing key.
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
  • Front-end & mobile

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
cd-intakeS3 ObjectCreated + SES inboundPairs the two halves on till, business date and window10s / 512 MB
cd-readSQS shift queueTextract on photos, then one Bedrock call into denominations60s / 1024 MB
cd-explainSQS read queueThe five lookups against refunds, no-sales, float and drops10s / 512 MB
cd-windowSQS residual queueUpdates the rolling window and runs the three pattern tests10s / 512 MB
cd-reportEventBridge monthly + Function URLBuilds the summary; handles the signed cause-recording links30s / 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
cd-intake-roles3:GetObject, sqs:SendMessageThe uploads prefix; the shift queue only
cd-read-roletextract:AnalyzeDocument, bedrock:InvokeModelThe uploads prefix; one model arn
cd-explain-roledynamodb:UpdateItem, secretsmanager:GetSecretValueShifts table; the POS credential only
cd-window-roledynamodb:UpdateItem, ses:SendEmailWindows and causes; one verified identity
cd-report-roledynamodb:Query, ses:SendEmailAll three tables, 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: shifts

PK   till              S   till-2
SK   business_date     S   2026-07-11
     state             S   half | complete | explained | held
     counted           N   412.50
     expected          N   429.00
     denominations     L   [{value, count, confidence}]
     written_total     N   412.50   — the checksum from the sheet
     explanations      L   [{kind, amount, ref, confidence}]
     residual          N   5.50
     photo_keys        L   [s3 keys, kept as evidence]
     ttl               N   epoch, +7 years

The business date is NOT the calendar date. A 01:20 close belongs to the
previous trading day, and the cutover hour lives in the thresholds tab.

Table: windows

PK   till_slot         S   till-2|thu-evening
     residuals         L   last M occurrences, newest first
     short_count       N   how many of the last M were short
     over_count        N   how many were over
     spread            N   standard deviation of the window
     last_surfaced     S   2026-07-11T23:14:02Z

Table: causes

PK   till_slot         S   till-2|thu-evening
SK   recorded_at       S   2026-07-11T23:20:00Z
     shape             S   short|5.00|8.00|thu-evening
     cause             S   free text, in their words
     action            S   what changed, if anything
     recorded_by       S   manager@example.com
     suppress_until    S   2026-10-11

Suppression matches on shape, not on till. A pattern of a different
magnitude or direction surfaces immediately regardless of any cause.

Inbound and outbound

  • Photo uploads go straight to S3 with a presigned PUT from a small static page, so a phone on a shop connection is not holding a Lambda open while it uploads.
  • POS exports arrive either through an SES receipt rule writing to S3, or as a direct S3 drop if the point of sale can be pointed at a bucket. Both fire the same intake.
  • Cause-recording links in a surfaced pattern are signed, scoped to one pattern, single-use, and expire after thirty days.
  • No staff identifiers enter the system at any point. There is no rota integration, and adding one would change what this system is.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used to turn a Textract table into denomination rows. That is extraction.
  • Called once per photograph, keyed on the image digest, so a recount of the same photo does not pay again.
  • Output is a JSON schema with a denomination array and a per-row confidence, plus the written grand total as a separate field so it can be used as a checksum.
  • Grounded with the denominations in circulation for your currency, so the model matches rows to a fixed list rather than inventing a value.
  • Nothing about the explaining touches a model. Matching a variance to a refund is a comparison, and comparisons should be code.

Things worth knowing before you build it

  • Get the business-day cutover right before anything else. In hospitality a close after midnight belongs to the previous day, and getting it wrong makes every late-trading Friday look like a Saturday with no takings.
  • Use the written grand total as a checksum on the machine read. It costs nothing and it removes almost every phantom variance.
  • Do not join to a rota. The temptation is enormous and the resulting per-person report is dominated by which till somebody works rather than anything about them.
  • Suppress on pattern shape, not on till. A cause recorded for a five-pound Thursday shortfall must not hide a thirty-pound one.
  • Expire suppressions. A cause recorded in March is usually not true by July, and permanent suppressions accumulate into a system that reports nothing.

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

All posts