Attendance, Shifts & Time (ATT)
Module code ATT · Release R1 (MVP), device gateway R2 · Packaging Core · Requirements
FR-ATT-*,BR-ATT-*,RULE-ATT-*in the PRD and BRD · Related Leave, Payroll, Mobile, Notifications & Workflow
1. Overview and scope
ATT turns raw presence evidence into one attendance day per employee per date. The day carries a status, worked minutes, lates, early exits, overtime and a paid fraction. Payroll reads only these computed days, never raw punches. ATT is the first link in the attend-to-pay chain and the module with the most configuration, so its design goal is one typed, effective-dated Attendance Policy and one deterministic day-computation engine.
In scope (R1)
- Punch capture: web, mobile selfie with geo-fence, biometric CSV/API import, Excel upload, manager entry. Kiosk and face verification are R2 (Mobile).
- Shifts (fixed, flexible, night/cross-midnight), shift assignment, rotation sequences, week-off patterns.
- The Attendance Policy: graces, half/full-day thresholds, late rules and late-to-leave deduction, missed-punch default out-time, breaks, overtime, extra-day pay and week-off penalty.
- The day computer, which re-runs on any input change and is idempotent.
- Requests: attendance request (WFH, WFO, on-duty, remote location), regularization, permission (short leave), shift change and swap, overtime claim. Every request goes through the approval engine.
- Period lock aligned with the pay schedule, and post-lock adjustments.
- Reports and dashboards for employees, managers, HR and owners.
Out of scope: drag-and-drop roster planning and coverage view (R3 candidate), anomaly detection (R4), Wi-Fi BSSID verification (R2), timesheets (TSM).
2. Learned from the reference product
Source: Spryple analysis, §5, §16 and §21.3.
- [Observed] Attendance comes from many channels: biometric mapping, Excel upload with a vendor template, mobile face check-in, a receptionist "front office" check-in and a CRM photo punch. Punch records carry a source ("Lite", "CRM", "MOBILEAPP"), an address and first/last check-in photos (SP-ATT-13, SP-ATT-14, SP-ATT-19, SP-ATT-20; A: Attendance, MOB).
- [Observed] Shift-work features are rich: rotations, per-employee week-offs, shift-change requests where the manager can swap shifts between employees, one permission per day, and regularization that converts a day to leave (SP-ATT-06 to SP-ATT-12; M: Attendance).
- [Observed] Rules are scattered: lates appear in the shift master and HR Policy rules 22–24; default shift in rules 19–21 and 25–26; OT is only "hours beyond the shift" with no approval (SP-ATT-01, SP-IAM-03; A: Employee Mgmt).
- [Observed] The day-status legend is A, P, W, L, H, HD, WP, HP, HL, and counts accept halves (e.g. 14.5). Geo-fencing is a single radius per branch, e.g. 200 m (§21.3, screenshots).
- [Observed] Manager "on behalf" attendance requests are auto-approved (SP-ATT-04; SP-X-04).
- [Inferred] There is no documented attendance freeze per payroll period, so late corrections after payroll would silently change history (§5.4).
3. Our design decisions
| ID | Decision [Proposed] | Reason |
|---|---|---|
| D-ATT-01 | One Attendance Policy object, versioned and effective-dated, assigned by applicability (location, department, designation, employment type, grade). It holds graces, thresholds, late rules, OT, extra days and penalties. | Removes the duplicated, contradictory rules seen in the reference product. Changes apply to existing employees from a chosen date. |
| D-ATT-02 | Shifts are a separate master; the policy references them. A shift defines time windows only, not deductions. | Keeps "when do you work" apart from "how is it judged". |
| D-ATT-03 | A deterministic day computer: att_day = f(punches, shift, policy version, approved requests, leave, holidays). It stores an input_hash and recomputes on any input event. | Late biometric data, approvals and policy edits are all just recomputes. It is idempotent and testable with golden cases. |
| D-ATT-04 | A day has two halves, each with its own status code. The day status is derived from the halves. | Half-day leave, half-day WFH and half-day absence combine without special cases. The HL and HD legends fall out naturally. |
| D-ATT-05 | Period lock per pay period. After lock, changes create att_adjustment rows that flow to the next pay run as LOP reversal or arrears. | Locked payroll must be reproducible. Corrections still reach the employee. |
| D-ATT-06 | Punches are append-only and deduplicated; edits are new punches with source = MANUAL and a reason. | Audit evidence; the selfie and geo trail are never overwritten. |
| D-ATT-07 | Geo-fences are a list per location (circle with radius or polygon) with a policy action: BLOCK, FLAG or ALLOW. | The reference product has one radius per branch. Large campuses and multi-gate sites need several. |
| D-ATT-08 | OT needs approval when the policy says so, and has a compensation mode: pay, comp-off or employee choice. | OT is a cost line; unapproved "hours beyond shift" is not acceptable to finance. |
| D-ATT-09 | On-behalf requests by a manager are auto-approved for the levels the manager covers (see NTF). | Parity with SP-X-04 without bypassing a second-level HR step where one is configured. |
| D-ATT-10 | Shift swaps require consent from the counterpart before the manager approves. | The reference product swaps on approval without consent. |
4. Domain model
Notes:
att_punchis high volume. Partition it by month onpunched_at.dedupe_key= hash of employee, minute bucket, direction and source family.att_dayhas a unique key(tenant_id, employee_id, work_date). It is a projection: it can always be rebuilt.- Day status codes:
Ppresent,Aabsent,HDhalf day,Wweek-off,Hholiday,Lleave,HLhalf-day leave,WPweek-off present,HPholiday present,ODon duty,WFHwork from home,NJnot yet joined,SEPafter separation. Half codes use the same set (P,A,L,OD,WFH,W,H). The first nine match the reference legend [Observed]; the rest are [Proposed]. - Face templates and kiosk enrolment live in Mobile (
mob_face_profile). Geo-fences live in ORG (org_geofence).
5. State machines
5.1 Attendance request (all types)
Withdrawn replaces the reference product's soft "Deleted" status. A shift-swap request adds a AwaitingCounterpart step between Submitted and InApproval.
5.2 Attendance day
5.3 Period lock
6. Key business logic
6.1 Resolving the shift and policy for a date
function resolveContext(employee, date):
assignment = shiftAssignment(employee) where valid_from <= date and (valid_to is null or valid_to >= date)
if assignment.rotation_id:
offset = days_between(assignment.valid_from, date) mod rotation.cycle_days
shift = rotation step covering offset
else:
shift = assignment.shift or policy.default_shift
weekoff = assignment.weekoff_pattern or policy.default_weekoff_pattern
policy = highest-priority att_policy_assignment whose applicability matches the
employee's job assignment on that date (location, department, designation,
employment type, grade); take the att_policy_version valid on that date
holiday = holiday on date in the employee's location calendar (see ORG)
return shift, weekoff, policy, holiday
6.2 Assigning punches to a work date (night shifts)
window_start = date + shift.start_time - shift.early_window_minutes
window_end = date + shift.end_time + shift.late_window_minutes
if shift.crosses_midnight: window_end = window_end + 1 day
punches(date) = punches where window_start <= punched_at < window_end, after dedupe
# A punch falling in two windows (back-to-back shifts) goes to the window whose
# shift start is nearest.
Dedupe: punches by the same employee within dedupe_seconds (default 60) are one event; the earliest IN and the latest OUT win. When multiple sources are present on one day, break deductions are skipped, matching the reference rule [Observed §21.3].
6.3 The day computation
function computeDay(employee, date):
ctx = resolveContext(employee, date)
if date < employee.joining_date: return day(status=NJ, paid=0)
if date > employee.last_working_date: return day(status=SEP, paid=0)
base = HOLIDAY if ctx.holiday else (WEEK_OFF if ctx.weekoff.matches(date) else WORKING)
p = punches(date)
approvedLeave = LVE.approvedHalves(employee, date) # e.g. {first: CL}
approvedReq = approved att_request halves (WFH, OD, remote) for date
# 1. Pair punches
first_in = earliest IN, or earliest punch if direction unknown
last_out = latest OUT after first_in
if first_in and not last_out:
if policy.missed_punch.mode == DEFAULT_OUT:
last_out = date + policy.missed_punch.default_out_time ; flag auto_out
else:
missed_punch = true
worked = minutes(first_in, last_out) - breakMinutes(policy, p)
# 2. Late and early
late = max(0, first_in - (shift.start + policy.grace_in_minutes))
early = max(0, (shift.end - policy.grace_out_minutes) - last_out)
if shift.is_flexible: late = 0 ; early = 0 ; judge by worked minutes only
# 3. Half statuses, highest precedence first
for half in [first, second]:
if approvedLeave[half]: status[half] = L
elif approvedReq[half]: status[half] = approvedReq[half].code # OD, WFH
elif base == HOLIDAY: status[half] = H
elif base == WEEK_OFF: status[half] = W
else: status[half] = pending
# 4. Judge worked time on working halves
if any half pending:
if missed_punch: mark pending halves A, needs_action
elif worked >= policy.full_day_minutes: pending halves -> P
elif worked >= policy.half_day_minutes: first pending half -> P, other -> A
else: pending halves -> A
# 5. Work on off days
if base in (HOLIDAY, WEEK_OFF) and worked >= policy.offday_min_minutes:
status = HP or WP ; eligible_for = policy.offday_compensation # PAY or COMP_OFF
# 6. Overtime
ot = worked - shift.duration if base == WORKING else worked
ot = 0 if ot < policy.ot.min_minutes else roundDown(ot, policy.ot.rounding_minutes)
ot = min(ot, policy.ot.daily_cap_minutes)
# 7. Paid fraction (input to payroll)
paid = sum over halves of 0.5 if half in (P, L_paid, OD, WFH, W, H) else 0
# L_paid excludes LOP; W and H paid unless the week-off penalty applies (6.5)
# 8. Persist only if input_hash changed; emit att.day.computed
Precedence order (highest first): separation/not-joined, approved leave, approved attendance request, holiday, week-off, punches. A holiday outranks a week-off for display; both are paid.
6.4 Late rule and late-to-leave deduction
monthly at period close, and on each late day (to warn early):
lates = count of days in the pay period with late_minutes > 0
excluding days where an approved permission covers the late minutes
free = policy.late.free_lates_per_period # e.g. 3
if lates > free:
blocks = floor((lates - free) / policy.late.block_size) # e.g. every 3 lates
deduct = blocks * policy.late.deduct_days_per_block # e.g. 0.5 day
for leaveType in policy.late.deduct_from_order: # e.g. CL, EL, LOP
take = min(deduct, LVE.available(employee, leaveType))
LVE.post(LATE_DEDUCTION, leaveType, take) ; deduct -= take
if deduct > 0: LVE.post(LATE_DEDUCTION, LOP, deduct)
emit att.late.threshold_breached
This replaces both the shift-master late setting and HR Policy rules 22–24 in the reference product.
6.5 Week-off penalty (sandwich for absence)
for each week-off or holiday day D in the period:
if policy.weekoff_penalty.enabled and employee designation in policy.weekoff_penalty.designations:
before = previous working day status ; after = next working day status
if before in (A, LOP) and after in (A, LOP): # "both" mode
D.paid_fraction = 0 ; D.penalty = true
# Mode "either" penalises when either side is absent. Mode "lop_threshold" applies a
# proportional week-off deduction once LOP days exceed N (payroll rule, see PAY).
6.6 Extra-day pay slabs
worked_days = count of P plus half of HD in the period on working days
slab = policy.extra_days.slabs for the employee's designation where min <= worked_days <= max
extra_days = slab.eligible_extra_days limited to count of WP and HP days
emit as payroll input EXTRA_DAYS (quantity) at the pay-run snapshot
6.7 Geo-fence check at punch time
fences = org_geofence for the employee's allowed locations (home location plus
any location granted by the policy, e.g. client sites for field staff)
inside = any fence where haversine(point, fence.center) <= fence.radius_m + min(accuracy_m, policy.geo.max_accuracy_slack_m)
or point within fence.polygon
if accuracy_m > policy.geo.max_accuracy_m: result = LOW_ACCURACY
result = INSIDE if inside else OUTSIDE
action = policy.geo.outside_action # BLOCK, FLAG or ALLOW
if action == BLOCK and result != INSIDE: reject punch with a clear reason
store geofence_result on the punch
6.8 Regularization and permission rules
- Regularization lookback: only dates within
regularization.lookback_daysand inside an open period. A regularization can set a missing out-time, a missing in-time, or convert the day to leave. Converting to leave creates an LVE request (LOP only when no balance, per [Observed] SP-ATT-11). - Permission: at most
permission.max_per_day(default 1, as observed) andpermission.max_per_period;permission.max_minuteseach; paid or unpaid perpermission.paid. An approved permission offsets late or early minutes on that day.
7. Configuration
All settings live in the Attendance Policy version unless marked otherwise. Types are JSON Schema types in packages/policy.
| Setting | Type | Default | Description |
|---|---|---|---|
default_shift_code | string | tenant's first shift | Shift used when no assignment exists |
default_weekoff_pattern | string | SUN | Pattern code, e.g. SUN, SAT_SUN, 2ND_4TH_SAT_SUN |
grace_in_minutes | integer | 10 | Minutes after shift start before a punch counts as late |
grace_out_minutes | integer | 0 | Minutes before shift end before a punch counts as early exit |
full_day_minutes | integer | 480 | Worked minutes for a full day |
half_day_minutes | integer | 240 | Worked minutes for a half day |
break.mode | enum | FIXED | FIXED deducts break_minutes from the shift; PUNCHED uses break punches; NONE |
missed_punch.mode | enum | NEEDS_ACTION | NEEDS_ACTION or DEFAULT_OUT |
missed_punch.default_out_time | time | shift end | Out-time applied in DEFAULT_OUT mode |
late.free_lates_per_period | integer | 3 | Lates allowed before deduction |
late.block_size | integer | 3 | Lates per deduction block |
late.deduct_days_per_block | number | 0.5 | Leave days deducted per block |
late.deduct_from_order | array of string | [CL, EL, LOP] | Leave types tried in order |
permission.enabled | boolean | true | Allows short-leave requests |
permission.max_per_day | integer | 1 | Requests per day |
permission.max_per_period | integer | 2 | Requests per pay period |
permission.max_minutes | integer | 120 | Minutes per request |
permission.paid | boolean | true | Whether permission minutes are paid |
regularization.lookback_days | integer | 7 | How far back an employee may regularize |
regularization.max_per_period | integer | 5 | Regularizations per period |
regularization.allow_on_weekoff | boolean | false | Allows regularizing week-off days |
geo.required | boolean | true | Mobile punches need a location |
geo.outside_action | enum | FLAG | BLOCK, FLAG or ALLOW |
geo.max_accuracy_m | integer | 100 | Readings worse than this are LOW_ACCURACY |
geo.max_accuracy_slack_m | integer | 30 | Accuracy allowance added to the fence radius |
selfie.required | boolean | true | Mobile punch needs a selfie |
face_match.mode | enum | OFF | OFF, FLAG or BLOCK (R2 add-on) |
ot.enabled | boolean | false | Overtime is computed |
ot.min_minutes | integer | 30 | Below this, OT is zero |
ot.rounding_minutes | integer | 15 | Round OT down to this block |
ot.daily_cap_minutes | integer | 240 | Daily OT cap |
ot.period_cap_minutes | integer | 3000 | Cap per quarter; set it to the applicable statutory limit (example, verify per state) |
ot.requires_approval | enum | POST | NONE, PRE or POST |
ot.multiplier | number | 2.0 | Pay multiplier on the ordinary hourly rate (statutory example, verify) |
offday_min_minutes | integer | 240 | Minimum minutes worked on an off day to count as WP or HP |
offday_compensation | enum | EMPLOYEE_CHOICE | PAY, COMP_OFF or EMPLOYEE_CHOICE |
extra_days.enabled | boolean | false | Extra-day pay slabs apply |
extra_days.slabs | array of object | [] | Per designation: min_days, max_days, eligible_extra_days |
weekoff_penalty.enabled | boolean | false | Week-off absence penalty applies |
weekoff_penalty.mode | enum | BOTH_SIDES | BOTH_SIDES or EITHER_SIDE |
weekoff_penalty.designations | array of uuid | all | Designations the penalty applies to |
dedupe_seconds | integer | 60 | Punches closer than this are merged |
lock.blocking_checks | array of string | [PENDING_REQUESTS, MISSED_PUNCH] | Issues that block a period lock without override |
8. Screens by persona
| Persona | Web | Mobile |
|---|---|---|
Employee (employee) | My calendar with legend, today's shift and next shift, punch (when web punch allowed), requests (attendance, regularization, permission, shift change, OT), alerts for days needing action, history | Punch with selfie and geo, offline queue indicator, calendar, requests, alerts |
| Field executive | As employee; punch locations include client sites | Punch at client site with photo (shared with FFM) |
| Line manager | Team calendar (absentees, pending approvals), approval inbox with bulk actions, on-behalf requests, shift assignment, rotations, week-offs, swap approvals, team regularization (multi-select) | Approvals, today's attendees, team calendar, on-behalf punch request |
| HR manager / admin | Attendance Policy editor with preview ("who does this apply to"), shifts, rotations, week-off patterns, device mapping, Excel upload (dry-run), recompute tool, period lock console with blockers, adjustments queue | — |
| Kiosk operator | — | Kiosk punch (R2, Mobile) |
| Org owner | Attendance dashboard: present/absent/late by location, work-type split, trend | Dashboard tiles |
9. Events
| Event | Emitted when | Main consumers |
|---|---|---|
att.punch.recorded | A punch is accepted (any source) | ATT day computer, FFM (track session start/stop) |
att.punch.rejected | A punch fails geo or face checks in BLOCK mode | NTF (notify employee), RPT |
att.day.computed | An att_day row is created or changed | LVE (comp-off eligibility), PAY (input invalidation), RPT |
att.day.needs_action | A day becomes missed-punch or unexplained absent | NTF (employee alert, reminder) |
att.request.submitted | Any attendance request is submitted | NTF (start workflow) |
att.request.approved | Workflow completes with approval | ATT recompute, NTF |
att.request.rejected | Workflow rejects | NTF |
att.request.cancelled | A cancellation is accepted | ATT recompute |
att.overtime.approved | OT minutes are approved | PAY (OT input) |
att.late.threshold_breached | Late count crosses the deduction threshold | LVE (post deduction), NTF |
att.period.locked | A period is locked | PAY (snapshot inputs), NTF |
att.period.unlocked | A period is reopened | PAY (invalidate draft run) |
att.adjustment.created | A post-lock change is recorded | PAY (next-run input) |
Consumed: lve.request.approved, lve.request.cancelled (recompute affected days), org.holiday.changed (recompute the location's future and open days), emp.job.changed (re-resolve policy), emp.employee.joined, ext.lwd.reached, pay.run.locked (close the period), ffm.visit.checked_in (optional auto-punch).
10. API surface
Detailed in API endpoints.
| Resource | Main operations |
|---|---|
/v1/att/punches | POST (mobile/web punch with selfie upload reference), GET (list by employee and date range) |
/v1/att/punches/import | POST CSV or Excel batch with dry_run=true |
/v1/att/devices and /v1/att/devices/:id/user-maps | CRUD; device ingest uses ingest.spyrl.app with a device secret |
/v1/att/days | GET by employee, team or location and range; POST /v1/att/days/recompute (HR) |
/v1/att/requests | POST, GET, PATCH (while Submitted), POST /:id/withdraw, POST /:id/cancel |
/v1/att/shifts, /v1/att/rotations, /v1/att/weekoff-patterns | CRUD |
/v1/att/shift-assignments | POST bulk assign with date range, GET |
/v1/att/policies and /v1/att/policies/:id/versions | CRUD, publish version, GET /:id/applicability-preview |
/v1/att/period-locks | POST (start lock with checks), POST /:id/unlock |
/v1/att/adjustments | GET |
11. Permissions
| Permission | Typical scopes | Default roles |
|---|---|---|
att.punch.create | SELF | employee, field_executive |
att.punch.create_for_others | LOCATION | kiosk_operator, hr_manager |
att.punch.import | ORG, LOCATION | hr_manager, hr_admin |
att.day.read | SELF, TEAM, LOCATION, ORG | employee (SELF), line_manager (TEAM), hr_manager, org_owner, auditor |
att.day.recompute | ORG | hr_admin |
att.request.create | SELF | employee |
att.request.create_on_behalf | TEAM, LOCATION | line_manager, hr_manager |
att.request.approve | TEAM, LOCATION, ORG | line_manager, hr_manager |
att.shift.manage | ORG | hr_admin |
att.shift.assign | TEAM, LOCATION | line_manager, hr_manager |
att.policy.manage | ORG | hr_admin |
att.device.manage | ORG | hr_admin |
att.period.lock | ORG, LOCATION | hr_manager, payroll_manager |
att.period.unlock | ORG | hr_admin (with reason; audited) |
att.selfie.read | TEAM, ORG | line_manager, hr_manager (sensitive; audited) |
att.report.export | TEAM, ORG | line_manager, hr_manager, org_owner |
12. Reports
All reports use the RPT framework: one dataset, scoped by the viewer's permission, exported to Excel or PDF, schedulable.
| Report | Contents | Reference |
|---|---|---|
| Monthly attendance register | Grid of status codes per day with present, absent, leave, late counts and halves | [Observed] SP-ATT-16 |
| Daily attendance (current day) | Present, absent, late, on leave, not punched; check-in photo on demand | [Observed] SP-ATT-16 |
| Punch log | Every punch with source, location, geo result, device | [Observed] SP-ATT-16 |
| Attendance summary | First in, last out, worked, productive, OT and permission hours | [Observed] SP-ATT-16 |
| Late and early-exit report | Late minutes per day and deduction blocks | [Observed] SP-ATT-16 |
| Missed punch and regularization | Days needing action, regularizations raised and approved | [Observed] SP-ATT-16 |
| Shift roster and shift-wise headcount | Present over planned per shift per day | [Observed] §21.3 |
| Overtime report | Computed vs approved OT, cost estimate | [Proposed] |
| Extra days and penalty | WP/HP days, eligible extra days, penalised week-offs | [Observed] SP-ATT-16 |
| Payable-days preview | Paid fraction totals per employee for the open period | [Proposed] |
| Geo exceptions | Punches outside fence or low accuracy, by location | [Proposed] |
| Post-lock adjustments | Adjustments waiting for or applied in the next run | [Proposed] |
13. Release and priority
| Capability | Release | MoSCoW |
|---|---|---|
| Web and mobile punch with selfie and geo-fence; shifts; week-offs; day computer | R1 | Must |
| Rotations, shift change and swap, permissions, regularization, on-behalf | R1 | Must |
| OT with approval, extra days, week-off penalty, late deduction | R1 | Must |
| Biometric CSV/API import, device user mapping | R1 | Must |
| Period lock and adjustments | R1 | Must |
| Biometric push gateway (ADMS-style) and sync agent | R2 | Should |
| Kiosk mode and face verification with liveness | R2 | Should |
| Wi-Fi BSSID and QR fallback for punches | R2 | Could |
| Roster planner (drag and drop) and coverage view | R3 | Could |
| Attendance anomaly detection | R4 | Could |
14. Open questions
- Which biometric vendors and protocols do pilot customers use (ZKTeco/eSSL push, polling or file)? This decides whether the R2 gateway moves to R1.
- Should a holiday worked by a night-shift employee be judged on the shift-start date or the calendar date? The default is the shift-start date.
- Do we allow negative paid fractions (e.g. penalty days beyond the day itself), or keep penalties as LOP inputs to payroll? The default keeps them as LOP inputs.
- What retention for punch selfies satisfies customers' audit needs and DPDP minimisation? The architecture default is 180 days.
- Is "permission" a leave type (LVE) or an attendance request (ATT)? The decision here is ATT, because it offsets minutes rather than days.