Skip to content

Part 7 of 7 · Petty cash tracker series ~7 min read

Engineering reference: the petty cash 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, the concurrency story, 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 petty cash tracker drawn with AWS service namesThree boxes across the top outside the AWS account. The Capture page, served as static files from S3 behind CloudFront. The Custodian list, read through the Google Sheets API read-only. And SES outbound, carrying count prompts and mismatch messages. Inside the account, three groups. S3 holding receipt photographs and SQS carrying one receipt queue. Four Lambda functions named capture, read, count and close. And two DynamoDB tables named floats and movements. A note gives the region as us-east-1, one account, and states that no path in this system moves money or authorises a spend.AWS ACCOUNTCapture pageCloudFront + S3Custodian listSheets API, read-onlySES outboundcounts, mismatchesS3 + SQSreceipt photos,one receipt queueLambda x4capture, read,count, closeDynamoDB x2floats, movementsingroundsoutus-east-1. One account. No path in this system moves money or authorises a spend.
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
  • 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
pc-captureFunction URLMints a presigned PUT, resolves the float from the photographer10s / 512 MB
pc-readS3 ObjectCreatedTextract, then one Bedrock call for the total; writes the movement60s / 1024 MB
pc-countEventBridge + Function URLPrompts on cadence; records a count and reconciles it15s / 512 MB
pc-closeFunction URLServes the month view and builds the journal export30s / 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
pc-capture-roles3:PutObject, dynamodb:GetItemThe photos prefix; the floats table, read
pc-read-roletextract:AnalyzeExpense, bedrock:InvokeModel, dynamodb:UpdateItemThe photos prefix; one model arn; floats and movements
pc-count-roledynamodb:Query/PutItem, ses:SendEmailMovements; one verified identity
pc-close-roledynamodb:Query, s3:PutObjectMovements, read; the exports prefix

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: floats

PK   float_id          S   office-tin
     label             S   Office tin
     balance           N   46.60
     custodian         S   sam@example.com
     count_cadence     S   weekly | daily | monthly
     last_counted      S   2026-07-10
     difference_code   S   the account an adjustment posts to

The balance move:
  UpdateExpression: ADD balance :delta
Never SET. Two spends in the same second must both apply.

Table: movements

PK   float_id          S   office-tin
SK   moved_at          S   2026-07-16T09:14:02Z#mv_4f1a
     type              S   spend | topup | adjustment | count
     amount            N   -8.20   (signed; summing the column is the balance)
     photo_key         S   s3://petty/2026/07/....jpg
     vendor            S   or null
     category          S   set at month end, not at capture
     by                S   sam@example.com
     corrects          S   another SK, for an adjustment
     ttl               N   epoch, +7 years

Append-only. There is no code path in this system that deletes or
updates a movement row; a correction is a new row of type adjustment.

Inbound and outbound

  • The capture page is static files in S3 behind CloudFront with an origin access control. The link carries a signed staff token minted when somebody is added to the custodian list.
  • Photos upload with a presigned PUT straight to S3, so a phone on a poor connection is not holding a Lambda open. The S3 event is what fires the read.
  • The blur and edge check runs on the device before upload, because a retake is trivial while the receipt is in hand and impossible an hour later.
  • Count links in a prompt are signed, scoped to one float and one count window, and expire after seven days.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, asked for the amount actually paid from a Textract expense analysis.
  • Called once per photograph, keyed on the image digest, so a retry or a re-read never pays twice.
  • The prompt is explicit that a cash-tendered line and a change line are not the total, which is the single most common extraction error on a till receipt.
  • Output is a JSON schema with total, vendor and date, all nullable. A null total produces a question with the crop; it never produces a zero or a best guess.
  • Category is never attempted. Categorising is a bookkeeping judgement made once a month by one person with the chart of accounts in front of them.

Things worth knowing before you build it

  • Use ADD, not SET, on the balance. It is one word and it is the difference between a float you can trust and one where a busy afternoon silently loses a spend.
  • Hide the expected balance while somebody is counting. A number on the screen anchors the count and the reconciliation stops being independent.
  • Never delete a movement. A correction is a new row referencing the old one, or the float stops being evidence.
  • Store a downscaled legible copy alongside the original photograph, and expire the original on a lifecycle rule at your actual record-keeping horizon.
  • Block the export on an uncategorised receipt. It is the only hard block in the system and it prevents petty cash becoming one growing suspense line.

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

All posts