Skip to main content

Clock Attendance

Module path: apps/backend/src/attendance/ Feature branch: 058-attendance Spec: specs/058-attendance/spec.md PWA: apps/frontend-pwa/src/pages/attendance/, apps/frontend-pwa/src/components/forms/attendance/kiosk/

Work schedules, a PIN kiosk on the shared POS tablet, append-only clock events, days derived from those events, exceptions a manager answers, and an ApprovedAttendancePort that payroll reads. It is a sibling of HR (056) and payroll (057), not a layer inside either.

It ships no timesheets against projects — PSA already has those — no scheduling optimiser, no geofencing and no biometrics.

Four rules bind everything here:

  • An event is never edited or deleted. A correction is a new row pointing at the one it supersedes. A trigger enforces it below the API.
  • Locked is derived, never stored. A day is locked when a closed payroll run covers its date, asked at read time.
  • A day reaches payroll only after a manager approves it, and only if no blocking exception is open.
  • Location is a boundary, on reads and on writes, because the platform's LocationAccessGuard cannot see these requests.

Architecture​

apps/backend/src/attendance/
attendance.module.ts # imports DatabaseModule, HrModule,
# PayrollClosedPeriodModule, UserLocationAccessModule
attendance-approved-totals.module.ts # the one thing payroll imports from attendance
domain/
attendance-event-repository.domain.ts
attendance-day-repository.domain.ts
work-schedule-repository.domain.ts
derive-day-queue.port.ts # derivation leaves the request through this
errors/attendance.errors.ts
application/
derive-day.service.ts # pure `deriveDay()`; reads no other date
attendance-derivation.service.ts # orchestrator; pairs events, then filters
attendance-event.service.ts # the two PIN-failure paths
attendance-approval.service.ts # approve, reopen, resolve — and location scoping
attendance-board.service.ts
schedule-assignment.service.ts, work-schedule.service.ts, schedule-exception.service.ts
infrastructure/
attendance-event.repository.ts, attendance-day.repository.ts, work-schedule.repository.ts
approved-attendance.adapter.ts # what payroll reads
derive-day.processor.ts # the Bull consumer
jobs/close-missing.scheduler.ts # hourly sweep; re-enqueues, never raises
interfaces/
attendance-event.controller.ts # POST only — no PATCH, no DELETE
attendance-board.controller.ts, attendance-schedule.controller.ts

The module cycle rule, in two halves​

Both halves have to hold, and neither implies the other:

  • AttendanceModule may import PayrollClosedPeriodModule. It may never import PayrollModule.
  • PayrollModule may import AttendanceApprovedTotalsModule. It may never import AttendanceModule.

src/attendance/__tests__/module-boots.spec.ts asserts this against Nest's own metadata, with an imports.length anchor. A plain "the app boots" test is not enough and was found to be vacuous: Attendance → Payroll → Hr → PayrollClosedPeriod is acyclic, so importing the whole of PayrollModule booted fine while violating the rule the file exists to state.

Derivation​

deriveDay() is a pure function. Given the events for one business date, the schedule window in force and the contracts in force, it produces one attendance_day row and the exceptions that go with it.

Two properties make the rest of the design work:

It reads exactly one date. A late Monday punch has no mechanism by which to reach an approved Friday. This is also why overtime is measured against the day's scheduled window rather than a weekly threshold — a 44-hour rule would require reading the whole week and would quietly reopen Friday's arithmetic.

Event selection pairs first and filters second. Filtering each event by its own calendar date drops the clock-out of an overnight shift, and the day then derives as missing_clock_out for somebody who clocked out perfectly normally at 02:00. See selectEventsForBusinessDate().

Derivation is idempotent and is enqueued by every punch, every schedule edit, every one-off override and the hourly sweep. A day is re-derived many times before anyone looks at it.

Breaks are deducted, never measured​

break_minutes is the scheduled break. Nothing anywhere observes whether it was taken. The PWA copy says so explicitly (FR-014b), because a column labelled only "Break" reads as measured, and the first dispute about it is a merchant certain the clock recorded something it never saw.

Exceptions​

Ten kinds. Four of them block: while one is open, the day is withheld from payroll.

KindBlocksWhy
missing_clock_out✅Derivation never invents an out time, so an unpaired clock-in derives zero. Approving a forgotten night shift would pay nothing while looking complete.
clock_while_suspended✅The punch is kept as evidence; whether the minutes are worked time is a decision, not a default.
unverified_offline_punch✅The PIN did not match and nobody could be told at the time.
ambiguous_contract✅Two contracts in force, so nobody can say which employer owes the day.
late, overtime, early_leave, absent, wrong_location, worked_on_rest_day—Informational. A manager approving the day has already seen them.

The list lives in BLOCKING_EXCEPTION_KINDS in packages/backend/database/src/types/attendance.types.ts and the PWA imports it rather than copying it. A second copy would drift, and it would drift silently: the board would offer an Approve button for a day the server then refuses, which reads to a merchant as the product being broken.

count_as_worked creates no salary​

It applies to exactly one kind — a punch during a suspension — and a database check constraint refuses it anywhere else. Even where it applies, it adds minutes to the attendance record and nothing else: pay during a suspension is still governed by the 057 suspension matrix. The PWA says this next to the button.

A scheduled no-show produces no row​

Worth knowing before you go looking for it. absent is raised by derivation, and derivation runs for a (employee, date) pair. Somebody who was scheduled and never touched the kiosk generates no events, so nothing enqueues a derivation for them and no day row exists to carry the exception. This is recorded as FR-016a and is a gap, not an oversight — closing it needs a sweep over schedules rather than over events.

Locked is derived, not stored​

There is no locked value in attendance_day_status. A day is locked when a closed payroll run covers its date for its employer entity, asked through ClosedPayrollPeriodPort at read time.

The alternative — payroll writing a flag when it closes — was rejected because it creates a second copy of "these minutes are already on a payslip", and the two can disagree with nothing marking the disagreement. Pulling the answer means there is only ever one.

An unattributed day (one still carrying ambiguous_contract) has no employer entity to ask about and cannot be on anyone's payslip, so it reports as unlocked. That is also the honest answer.

A tenant delete does not take the clock records with it​

Every attendance table's business_id is ON DELETE RESTRICT, following 2026-09-19t20-00-09-hr-business-fk-restrict. DELETE /businesses/:id is a hard delete, and clock records carry the same statutory retention as the contracts and salary history that migration protects — they are also the evidence behind what somebody was paid.

Making an event unforgeable while leaving one cascade able to remove every one of them would be a guarantee that holds against a typo and not against an API call.

Consequence: DELETE /businesses/:id fails for any business with attendance data. Tenant removal is an offboarding process, not a cascade nobody reviews. The purge hatch is what a deliberate retention purge uses, and what test cleanup uses.

What freezes on approval​

The trigger freezes more than the figures, because an approved day can otherwise change what it means without changing what it says:

FrozenWhy
the six minute columnsthe figures themselves
rule_versionwhich arithmetic produced them. Frozen minutes with a movable version is worse than no version — the row would claim v2 rules produced v1 numbers
contract_id, employer_entity_idwhose payroll the day lands on
approved_by, approved_atwho signed off. Attendance does not separate approver from subject, so this attribution is the only thing making the sequence reconstructible

status is deliberately not frozen — that is how reopen works, and a trigger refusing it would break the only legitimate way to correct an approved day.

One consequence for test authors: a day that should be unattributed has to be detached before approval, which is also the only order production can produce it in.

FR-018 is defended three times, deliberately​

An approved day is not silently re-derived. That holds because of three independent layers:

  1. an early return in AttendanceDerivationService,
  2. WHERE status = 'pending' in upsertDerived,
  3. a database trigger on the minute columns.

This was discovered while planting: removing any one of them left the guarantee intact and the test green. The redundancy is kept and commented, but be aware of it — a change that looks safe because the test still passes may have removed one of three walls.

Location scoping​

LocationAccessGuard is global, and reads as though tenancy is handled. It is not: it only acts on a request that literally carries a locationId field.

GET /attendance/days?businessDate=… carries none. Neither does POST /attendance/days/:id/approve. Both pass every guard untouched. So attendance scopes itself, in two places:

  • Reads — listForBoard on both repositories requires the permitted-location list in its signature, so an unscoped board query is not something a future caller can write by accident.
  • Writes — AttendanceApprovalService.requireDay takes the acting user and refuses a day at a location they are not assigned to. This is the half that is easy to miss, because the day arrives already identified by id and nothing in the handler looks like a query that needs scoping.

A day at a forbidden location is reported as not existing rather than forbidden. "You may not touch that one" confirms it exists, and day ids travel — they come back from boards, links and exports.

The kiosk​

/attendance/kiosk is a sibling of AuthenticatedLayout inside PrivateRoute, following the DiscoveryLayout precedent. The existing ?kiosk=true search flag is only a layout hint and still renders the app header, so it does not close the door it appears to.

KioskLayout checks attendanceModule itself. Routes outside AuthorizedMenuRoutes bypass filterMenuByAccess and inherit no module gating.

It composes NumericPinKeypad but not useEmployeeCodePinFlow. That hook resolves the employee client-side via selectEmployeeByCode for the POS; the attendance endpoint deliberately does not, so the keypad cannot be walked to learn which employee codes exist. Code and PIN go to the server together and the refusal is identical either way.

The offline queue​

Punches go into a Workbox backgroundSync queue named attendance-queue — its own queue, not the shared api-queue. Workbox replays a queue in order and stops at the first request it cannot send, so sharing would mean one stuck kitchen ticket holding every worker's clock-in behind it.

Workbox's queue is write-only from the page's point of view: it drops entries past maxRetentionTime and tells nobody. Left as it comes, the failure is a worker who clocks in during an outage, sees a tick, and finds out on payday the shift is missing. So:

  • attendanceQueueLedger.ts keeps the page's own row per queued punch;
  • useAttendanceQueue reconciles it against a direct read of the background-sync IndexedDB store;
  • KioskOfflineBanner reports both the depth and, as an alert that must be dismissed by hand, any discard.

The ledger carries no PIN and no hash, and refuses to read back a row that has one.

queuedOffline: true is set at the moment the kiosk decides to queue. The server needs it: a replayed offline punch with a wrong PIN cannot be answered with a 401 anybody will ever see, so it is stored and raises unverified_offline_punch instead. A live attempt with a wrong PIN is simply refused at the keypad.

Security residuals​

Three things this feature does not solve. They are design limits, not bugs, and anyone extending the module should know which is which.

1. Buddy punching is not prevented​

A four-digit PIN on a tablet anyone can pick up is a convenience, not an identity check — the person next to you can watch you type it. The copy on the kiosk never claims otherwise: it says the punch was recorded, not that anyone was identified, because the stronger claim is what someone is later disciplined or paid on.

Closing this needs a second factor at the device (a badge, a camera, a per-person token). Nothing in the schema assumes the PIN proved anything, so that can be added without rework.

2. An owner can approve their own attendance​

Attendance × Update is granted to administrator and store_manager; nothing stops a manager approving their own day. Segregation of duties is a payroll-approval concern (057 FR-036/FR-037) and is not re-implemented here. What this module provides is the record: every approval carries approved_by and approved_at, and a reopen requires a reason, so the sequence is reconstructible even where the permission is not separated.

3. A PIN is at rest on the tablet while queued​

The Workbox queue holds the request body — it must, or it could not replay it — and that body contains a plaintext PIN for up to 24 hours. This is inherent to offline capture. It is bounded three ways: maxRetentionTime of 24 hours, the per-device cap of 10 unverified punches per hour (UNVERIFIED_OFFLINE_PUNCH_LIMIT), and the ledger keeping no copy of its own.

Removing it entirely would mean hashing on the client, which requires shipping the salt to the tablet and gains nothing.

Testing notes​

  • Integration tests never skip. Use requireTestDatabase(). A skipped test reports its cases as passing.
  • Cleanup needs the purge hatch. attendance_event refuses an ordinary DELETE. Open a transaction, SET LOCAL attendance.allow_purge = 'on', then delete. SET LOCAL keeps the grant inside one transaction so it cannot leak onto a pooled connection. UPDATE has no such hatch and never will: a deleted punch leaves a visible gap, an edited one leaves a row that still looks original.
  • The fixture's managerUserId is assigned to both locations, because requireDay refuses a day at a store the caller cannot see and most suites are not about tenancy. The suite that is — interfaces/__tests__/location-scoping.integration.spec.ts — builds its own single-store user.
  • Every suite here carries a positive anchor. A file made only of refusals passes completely when the code refuses everything.
  • HR Foundation — employees, contracts, suspensions; attendance reads all of it through EmployeeDirectoryPort
  • Payroll Engine — consumes ApprovedAttendancePort, provides ClosedPayrollPeriodPort