Skip to content

Part 7 of 7 · Tip pool splitter series ~7 min read

Engineering reference: the tip pool splitter 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 decimal handling, and why there is no model anywhere in it.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 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 tip pool splitter drawn with AWS service namesThree boxes across the top outside the AWS account. Card and hours, arriving as point-of-sale and payroll exports. The Rule sheet, read through the Google Sheets API read-only. And SES outbound, carrying shares and period summaries. Inside the account, three groups. S3 holding the exports and EventBridge carrying a weekly schedule. Three Lambda functions named collect, split and publish. And two DynamoDB tables named periods and rotation. A note gives the region as us-east-1, one account, with no model and no path that moves money.AWS ACCOUNTCard + hoursPOS and payroll exportsRule sheetSheets API, read-onlySES outboundshares and summariesS3 + EventBridgeexports,weekly scheduleLambda x3collect, split, publishDynamoDB x2periods, rotationingroundsoutus-east-1. One account. No model, and no path that moves money.
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

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
tp-collectS3 ObjectCreated + Function URLCard figure, cash declaration, hours; completeness check20s / 512 MB
tp-splitEventBridge weeklyWeighted hours, rate, shares, remainder rotation20s / 512 MB
tp-publishSQS split queuePersonal messages, the period summary, and the payroll export30s / 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
tp-collect-roles3:GetObject, dynamodb:UpdateItemThe exports prefix; the periods table
tp-split-roledynamodb:UpdateItem, secretsmanager:GetSecretValuePeriods and rotation; the Sheets credential only
tp-publish-roleses:SendEmail, s3:PutObjectOne verified identity; 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: periods

PK   site              S   ashford
SK   period_end        S   2026-07-26
     state             S   collecting | split | published | corrected
     card              N   642.30
     cash              N   188.00
     cash_declared_by  S   ash@example.com
     cash_declared_at  S   2026-07-26T23:40:00Z
     rule              M   the weights and basis, copied at split time
     rate              S   “2.6077”   — stored as a string, see below
     shares            L   [{person, hours, weight, weighted, share}]
     remainder         N   0.02
     remainder_to      L   [sam@, kit@]
     supersedes        S   a previous version, for a correction

The rule is COPIED onto the period at split time. A later edit to the
sheet cannot restate a published period.

Table: rotation

PK   site              S   ashford
     order             L   a stable list of people
     position          N   4   — carried between periods, never reset

Resetting `position` each period is the bug that makes the rotation
pointless: whoever is first in `order` would get a penny every week.

Inbound and outbound

  • Card and hours exports land in an S3 prefix from the point of sale and payroll. Both fire the same collect function.
  • The cash declaration comes through a Function URL from a signed staff link, and records who declared it and when as first-class fields rather than metadata.
  • Nothing is written back to payroll. The export is a file; paying people is payroll’s job and this system has no credential for it.
  • Personal share links are signed, scoped to one person and one period, and expire after ninety days.

The model call

  • There is no model in this system. Not in the request path, not in the reporting, not anywhere.
  • That is worth stating explicitly in a series where most systems use one: this problem is arithmetic and a model would only add a way to be wrong about money.
  • Use decimal arithmetic, not floats. Python’s Decimal with an explicit context; the rate is stored as a string so it round-trips exactly.
  • Round half up, not half even. Banker’s rounding is correct in many contexts and surprising here, and surprise is the thing this system exists to remove.
  • Compute the remainder from the pool, not by summing rounding errors. The two differ, and only the first is definitionally right.

Things worth knowing before you build it

  • Use Decimal, never floats. A rate of 2.6077 in binary floating point will eventually produce a share that is a penny out and nobody will be able to explain why.
  • Carry the rotation position between periods. Resetting it hands the same person a penny every week and makes the whole mechanism worse than useless.
  • Copy the rule onto the period at split time. A sheet edit must never be able to restate a published period.
  • Use final hours, not draft ones. A share that changes after publication costs more trust than a split published two days later.
  • Never round in the house’s favour, not even once, not even as a convention. It is four pounds a year and it is the loudest thing this system could say.

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

All posts