Skip to content

Part 7 of 7 · Holiday accrual tracker series ~7 min read

Engineering reference: the holiday accrual tracker architecture

The same system with the service names filled in: what reads a contract, what accrues a period, what moves a balance across a year end, and what settles a leaver.

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 holiday accrual tracker drawn with AWS service namesThree boxes across the top sit outside the AWS account. Timesheets arriving as CSV or by API once a pay period. Contracts as PDFs, on hire and on variation. And Leave requests from whatever the business already uses. Each connects to the AWS account container below. Inside are three components. S3 with EventBridge handling imports and the period close. Five Lambda functions covering contract, accrue, book, close and leaver. And three DynamoDB tables holding workers, the ledger and bookings. A note says eu-west-2, one account, contracts are read on change, timesheets land each pay period, and the year-end close is a scheduled job that writes carry-over lines and nothing else.AWS ACCOUNTTimesheetsCSV or API, oncea pay periodContractsPDFs, on hire andon variationLeave requestsfrom whatever thebusiness usesS3 + EventBridgeimports and theperiod closeLambda x5contract, accrue, book,close, leaverDynamoDB x3workers, ledger,bookingsingroundsouteu-west-2. One account. Contracts are read on change; timesheets land each pay period; the year-end close is a scheduled job that writes carry-over lines and nothing else.
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
  • Management

Region and account

  • Region: eu-west-2. London, because this is employment data about UK workers and there is no reason for it to leave. Bedrock model availability is checked here rather than assumed; the contract read is the only step that would have to move if the model needed were not present, and it is the only step that is not arithmetic.
  • 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
hol-contractS3 put, contracts prefixReads leave year, entitlement, rate and any recovery term60s / 1024MB
hol-accrueEventBridge, on pay period closeTurns hours worked into one append-only ledger line per worker300s / 1024MB
hol-bookAPI Gateway, leave request eventsReserves, releases and deducts against the pot expiring first30s / 512MB
hol-closeEventBridge, per leave year boundaryApplies carry-over with a reason and a computed expiry300s / 2048MB
hol-leaverTermination eventCloses the part-period, values the balance, writes the evidence300s / 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
hol-contract-rolebedrock:InvokeModel, s3:GetObject, dynamodb:UpdateItemOne model id; the contracts prefix; workers
hol-accrue-roles3:GetObject, dynamodb:Query, dynamodb:PutItemThe timesheets prefix; workers read; ledger write, no update
hol-book-roledynamodb:Query, dynamodb:PutItem, dynamodb:UpdateItemledger read and append; bookings read and write
hol-close-roledynamodb:Query, dynamodb:PutItemledger read; ledger append. No delete on anything, ever
hol-leaver-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: workers

PK   employer_id#worker_id S
SK   ’terms#’ + effective  S   one item per contract version
     leave_year_start      S   MM-DD, or the hire anniversary
     leave_year_source     S   contract | policy | statutory_default
     accrual_rate          N   0.1207 unless the contract is better
     pot_split             M   {four_week, remainder, contractual}
     recovery_term         BOOL a written term allowing deduction on leaving
     evidence_key          S   the contract itself, not a summary

leave_year_source is stored because the statutory default is per-worker
and the other two are per-business. A system that only stores the date
cannot tell you which of its 46 year ends it invented.

Table: ledger

PK   employer_id#worker_id S
SK   period_end#seq        S   append-only; seq disambiguates corrections
     kind                  S   accrual | deduction | carry_in | carry_out |
                               adjustment | payout
     hours                 N   signed
     pot                   S   four_week | remainder | contractual
     leave_year            S   the window this line belongs to
     expires_on            S   set on carry_in lines only
     reason                S   sickness | family_leave | employer_failure |
                               agreement | leave_year_moved
     inputs                M   hours worked, rate, rounding, source file

Nothing in this table is ever updated or deleted. A correction is an
adjustment line carrying the same period_end and a later seq, so the
balance as at any past date is still recoverable.

Table: bookings

PK   employer_id#worker_id S
SK   booking_id            S
     from_date             S
     to_date               S
     hours                 N
     state                 S   requested | reserved | taken | cancelled
     deducted_seq          S   the ledger line, set when state becomes taken

state and deducted_seq together are the invariant: a booking in state
taken must name a ledger line, and a ledger deduction must name a
booking. A nightly check that both directions hold is four lines of
code and catches every partial write this system can produce.

Inbound and outbound

  • Timesheets are parsed, not read by a model. They are the largest files here and they are fixed-header exports from payroll or a rota tool. A model would be slower and would occasionally invent an hour.
  • Contracts are read once per version. A variation is a new item under the same worker, never an edit, so a balance calculated last March still knows which terms applied then.
  • Leave requests arrive over an API and the reservation is a conditional write. Two managers approving the same week is a condition failure rather than a lock.
  • Balances go out monthly by SES, to every worker, with what expires and when. This is the operational feature that closes the employer-failure carry-over route.

The model call

  • One call per contract version. Nothing per shift, per period, per booking or per balance query. All of those are arithmetic.
  • A mid-tier model. The extraction is four fields and a quotation for each, out of a long document. Length is not the same as difficulty.
  • Every extracted term comes back with the clause it came from, verbatim and with a page reference, because a disputed balance is settled by reading the clause rather than by trusting the field.
  • Absent means null, never a default. A contract that says nothing about recovering overtaken leave must produce null, not false and certainly not true. Null is the answer that stops the leaver job making a deduction.
  • No model touches accrual, deduction, carry-over or the payout. Every one of them has to be re-runnable years later and explainable to somebody who disagrees with the result.

Things worth knowing before you build it

  • Accrue on hours paid, from the same source payroll used. It is the only external check this system has, and it stops being a check the moment the two read different files.
  • Keep protected absence accruing. A worker on maternity leave or long-term sick works zero hours and accrues at the 52-week average, and omitting that is the largest single underpayment this design can produce.
  • Round accrual up, in every period, per worker. A rounding rule that favours the employer is indefensible exactly because it is consistent.
  • Store the pot each hour sits in. Four weeks are valued at normal remuneration and the rest at basic pay, and reconstructing the split at termination means reading two years of payslips under time pressure.
  • Compute carry-over expiry at the time of carrying and store the date. Recomputing it later against whatever the rules say then is how a balance silently changes.
  • Send the balance out monthly. It is a small feature that converts an open-ended liability into one that expires on schedule.

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

All posts