Your Attendance Data Is a Liability Until It Feeds Payroll Automatically
We audit HR operations for mid-size companies every month. The pattern is always the same: attendance is tracked in one system, overtime is calculated in a spreadsheet, and payroll gets a manually adjusted CSV. Every handoff is a place where hours get lost, overtime rules get misapplied, and employees file disputes.
Odoo 19 changes this. The Attendance module now supports kiosk mode with PIN and badge authentication, server-side geofencing validation, configurable overtime rules, and — critically — direct integration with payroll work entries. Check-in at the door, check-out at the end of the shift, and the hours flow into the payslip without a single spreadsheet in between.
This guide walks through the full stack: setting up kiosk hardware, configuring authentication methods, enforcing geofencing boundaries, writing overtime automation rules, and wiring everything into payroll. Every configuration is production-tested across warehouse, retail, and office environments.
How to Set Up Odoo 19 Attendance Kiosk Mode for On-Site Check-In and Check-Out
Kiosk mode turns any tablet or wall-mounted screen into a dedicated attendance terminal. Employees walk up, identify themselves, and check in or out with a single tap. Odoo 19 redesigned the kiosk interface with larger touch targets, faster camera initialization for badge scanning, and an offline queue that syncs when connectivity returns.
Step 1: Enable the Attendance Module
Navigate to Apps and install the hr_attendance module. If you plan to use payroll integration, also install hr_work_entry and hr_payroll at this stage — retrofitting them later requires re-mapping existing attendance records.
# Install attendance + payroll stack in one pass
./odoo-bin -c /etc/odoo/odoo.conf \
-d production_db \
-i hr_attendance,hr_work_entry,hr_payroll \
--stop-after-initStep 2: Configure Kiosk Mode Settings
Go to Attendance → Configuration → Settings. The kiosk settings panel in Odoo 19 has three key options:
| Setting | Options | Recommendation |
|---|---|---|
| Kiosk Mode | Enabled / Disabled | Enable for any site with shared terminals |
| Authentication | PIN / Badge / PIN + Badge / Manual | PIN + Badge for warehouses; PIN-only for offices |
| Kiosk URL | Auto-generated unique URL per kiosk | Bookmark on each terminal, lock browser to this URL |
Step 3: Deploy the Kiosk Terminal
The kiosk URL follows the pattern /hr_attendance/kiosk_mode/<token>. Each kiosk gets a unique token so you can track which terminal recorded which check-in. For a production deployment:
# Kiosk terminal nginx config — redirect all paths to kiosk URL
server {
listen 80;
server_name kiosk-terminal.local;
location / {
# Force redirect to the kiosk attendance page
return 302 https://erp.company.com/hr_attendance/kiosk_mode/abc123token;
}
}
# On the tablet itself (Android/ChromeOS):
# 1. Open Chrome, navigate to the kiosk URL
# 2. Enable "Add to Home Screen"
# 3. Use a kiosk lockdown app (e.g., Fully Kiosk Browser)
# to prevent employees from navigating away
# 4. Disable the status bar and navigation buttonsFor wall-mounted kiosks, we use Samsung Galaxy Tab A9+ in Fully Kiosk Browser. The 11-inch screen is large enough for comfortable interaction, Fully Kiosk handles the lockdown and auto-restart after power loss, and at ~$220 per unit, you can deploy multiple kiosks per site without budget drama. Connect via PoE adapter for reliable power and wired Ethernet.
PIN and Badge Authentication for Odoo 19 Attendance Kiosks
Kiosk authentication determines how employees identify themselves at the terminal. Odoo 19 supports four methods, and choosing the right one depends on your environment's security requirements and throughput needs.
PIN Authentication
Each employee gets a numeric PIN stored on their HR record. At the kiosk, they enter their PIN on a number pad and tap Check In or Check Out. The PIN is stored hashed in hr.employee — it is not reversible from the database.
import random
from odoo import api, SUPERUSER_ID
def assign_attendance_pins(env):
"""Assign unique 4-digit PINs to employees without one."""
employees = env['hr.employee'].search([
('pin', '=', False),
('company_id', '=', env.company.id),
])
used_pins = set(
env['hr.employee']
.search([('pin', '!=', False)])
.mapped('pin')
)
for emp in employees:
while True:
pin = str(random.randint(1000, 9999))
if pin not in used_pins:
emp.pin = pin
used_pins.add(pin)
break
return f"Assigned PINs to {{len(employees)}} employees"Badge (Barcode) Authentication
Badge authentication uses the barcode field on hr.employee. The employee scans their ID badge at a USB barcode reader connected to the kiosk tablet. The kiosk listens for barcode input events and matches against the employee database instantly.
def generate_employee_barcodes(env):
"""Generate unique EAN-8 barcodes for all employees."""
employees = env['hr.employee'].search([
('barcode', '=', False),
])
sequence = env['ir.sequence'].search([
('code', '=', 'hr.employee.barcode'),
], limit=1)
if not sequence:
sequence = env['ir.sequence'].create({
'name': 'Employee Badge Barcode',
'code': 'hr.employee.barcode',
'prefix': '042',
'padding': 5,
'number_increment': 1,
})
for emp in employees:
emp.barcode = sequence.next_by_id()
return f"Generated barcodes for {{len(employees)}} employees"Dual-Factor: PIN + Badge
For high-security environments (warehouses with controlled access, manufacturing floors), enable both. The employee scans their badge and enters their PIN. This prevents buddy punching — one employee checking in for an absent colleague using their badge.
A 2024 American Payroll Association study found that 75% of companies lose money to buddy punching. In a 200-employee warehouse, this translates to 5-8% of payroll hours being inflated. PIN + Badge dual authentication eliminates it entirely — you need both the physical badge and the knowledge of the PIN. For the highest security, Odoo 19 also supports facial recognition via camera, though this requires additional privacy compliance depending on your jurisdiction.
Enforcing Location-Based Check-In with Odoo 19 Geofencing
Geofencing restricts where employees can check in and out. If an employee tries to check in from a mobile device outside the allowed radius, the check-in is rejected or flagged for HR review. This is essential for field teams, multi-site operations, and any company with remote employees who should only log hours from approved locations.
Step 1: Define Work Locations
In Odoo 19, work locations are defined under Attendance → Configuration → Work Locations. Each location stores GPS coordinates and a radius in meters.
from odoo import models, fields, api
from odoo.exceptions import ValidationError
import math
class HrWorkLocation(models.Model):
_inherit = 'hr.work.location'
latitude = fields.Float('Latitude', digits=(10, 7))
longitude = fields.Float('Longitude', digits=(10, 7))
geofence_radius = fields.Integer(
'Geofence Radius (m)',
default=200,
help="Maximum distance in meters from this location "
"for a valid check-in.",
)
geofence_enabled = fields.Boolean(
'Enforce Geofencing',
default=False,
)
@api.constrains('geofence_radius')
def _check_radius(self):
for loc in self:
if loc.geofence_enabled and loc.geofence_radius <= 0:
raise ValidationError(
"Geofence radius must be greater than 0 meters."
)Step 2: Validate Check-In Coordinates
Override the attendance check-in method to validate the employee's GPS coordinates against their assigned work location. The Haversine formula calculates the distance between two GPS points on Earth's surface.
class HrAttendance(models.Model):
_inherit = 'hr.attendance'
check_in_latitude = fields.Float('Check-In Latitude', digits=(10, 7))
check_in_longitude = fields.Float('Check-In Longitude', digits=(10, 7))
check_in_distance = fields.Integer(
'Distance from Location (m)',
compute='_compute_distance',
store=True,
)
geofence_violation = fields.Boolean(
'Geofence Violation',
default=False,
)
@api.depends('check_in_latitude', 'check_in_longitude')
def _compute_distance(self):
for att in self:
loc = att.employee_id.work_location_id
if not (loc and loc.geofence_enabled
and att.check_in_latitude):
att.check_in_distance = 0
continue
att.check_in_distance = self._haversine(
att.check_in_latitude,
att.check_in_longitude,
loc.latitude,
loc.longitude,
)
@staticmethod
def _haversine(lat1, lon1, lat2, lon2):
"""Return distance in meters between two GPS points."""
R = 6_371_000 # Earth radius in meters
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lambda = math.radians(lon2 - lon1)
a = (math.sin(d_phi / 2) ** 2
+ math.cos(phi1) * math.cos(phi2)
* math.sin(d_lambda / 2) ** 2)
return int(R * 2 * math.atan2(
math.sqrt(a), math.sqrt(1 - a)
))
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for att in records:
loc = att.employee_id.work_location_id
if (loc and loc.geofence_enabled
and att.check_in_distance > loc.geofence_radius):
att.geofence_violation = True
# Notify HR manager
att._notify_geofence_violation()
return records
def _notify_geofence_violation(self):
"""Send an activity to the HR responsible."""
for att in self:
manager = att.employee_id.parent_id.user_id
if manager:
att.activity_schedule(
'mail.mail_activity_data_warning',
user_id=manager.id,
summary=f"Geofence violation: "
f"{{att.employee_id.name}}",
note=f"Checked in {{att.check_in_distance}}m "
f"from {{att.employee_id.work_location_id.name}} "
f"(limit: {{att.employee_id.work_location_id.geofence_radius}}m).",
)GPS accuracy inside buildings can degrade to 20-50 meters. Set your geofence radius to at least 150 meters for indoor locations to avoid false violations. For outdoor sites (construction, agriculture), 50-100 meters works well. If you're using kiosk terminals on-site, geofencing is unnecessary — the kiosk itself is proof of location. Use geofencing only for mobile check-ins from the Odoo app or web interface.
Configuring Overtime Rules and Automatic Calculation in Odoo 19
Overtime is where attendance tracking either saves money or creates lawsuits. Odoo 19's attendance module can compute overtime automatically based on your company's work schedule, but the default configuration handles only the simplest case. Real-world overtime rules involve tiers (1.25x for the first 2 hours, 1.5x after that), weekly caps, holiday multipliers, and union-specific exceptions.
Step 1: Define Resource Calendars
Overtime calculation starts with the resource calendar (working schedule). Go to Attendance → Configuration → Working Schedules and define your standard hours. Odoo compares actual attendance against this schedule to compute overtime.
<record id="standard_40h_schedule" model="resource.calendar">
<field name="name">Standard 40h/week</field>
<field name="hours_per_week">40</field>
<field name="tz">America/New_York</field>
<field name="attendance_ids" eval="[
(0, 0, {'name': 'Monday', 'dayofweek': '0',
'hour_from': 8.0, 'hour_to': 12.0}),
(0, 0, {'name': 'Monday', 'dayofweek': '0',
'hour_from': 13.0, 'hour_to': 17.0}),
(0, 0, {'name': 'Tuesday', 'dayofweek': '1',
'hour_from': 8.0, 'hour_to': 12.0}),
(0, 0, {'name': 'Tuesday', 'dayofweek': '1',
'hour_from': 13.0, 'hour_to': 17.0}),
(0, 0, {'name': 'Wednesday', 'dayofweek': '2',
'hour_from': 8.0, 'hour_to': 12.0}),
(0, 0, {'name': 'Wednesday', 'dayofweek': '2',
'hour_from': 13.0, 'hour_to': 17.0}),
(0, 0, {'name': 'Thursday', 'dayofweek': '3',
'hour_from': 8.0, 'hour_to': 12.0}),
(0, 0, {'name': 'Thursday', 'dayofweek': '3',
'hour_from': 13.0, 'hour_to': 17.0}),
(0, 0, {'name': 'Friday', 'dayofweek': '4',
'hour_from': 8.0, 'hour_to': 12.0}),
(0, 0, {'name': 'Friday', 'dayofweek': '4',
'hour_from': 13.0, 'hour_to': 17.0}),
]"/>
</record>Step 2: Enable Overtime Tracking
In Attendance → Configuration → Settings, enable "Count Extra Hours". This tells Odoo to compare actual check-in/check-out times against the resource calendar and compute the difference. You can also configure:
- Tolerance (minutes) — ignore small overruns. Set to 10 minutes to avoid counting employees who leave 5 minutes late as overtime.
- Start time rounding — round check-in times to the nearest 15-minute interval. Prevents employees from checking in 1 minute early and accumulating daily micro-overtime.
- Maximum overtime per day — cap daily overtime at a legal or policy maximum (e.g., 3 hours). Anything beyond is flagged for manual HR approval.
Step 3: Tiered Overtime Rules with Custom Module
Default Odoo tracks overtime as a flat number. Most labor laws require tiered rates — for example, 1.25x for the first 2 hours, 1.5x after that, and 2.0x on public holidays. This requires a custom extension:
class HrAttendanceOvertime(models.Model):
_inherit = 'hr.attendance.overtime'
overtime_tier1_hours = fields.Float(
'Tier 1 Hours (1.25x)',
compute='_compute_overtime_tiers',
store=True,
)
overtime_tier2_hours = fields.Float(
'Tier 2 Hours (1.5x)',
compute='_compute_overtime_tiers',
store=True,
)
overtime_holiday_hours = fields.Float(
'Holiday Hours (2.0x)',
compute='_compute_overtime_tiers',
store=True,
)
@api.depends('duration', 'date')
def _compute_overtime_tiers(self):
public_holidays = (
self.env['resource.calendar.leaves']
.search([('resource_id', '=', False)])
.mapped('date_from')
)
holiday_dates = set(
dt.date() for dt in public_holidays if dt
)
for ot in self:
total = ot.duration # total overtime hours
if ot.date in holiday_dates:
# All hours on holidays are 2.0x
ot.overtime_tier1_hours = 0
ot.overtime_tier2_hours = 0
ot.overtime_holiday_hours = total
else:
# Tier 1: first 2 hours at 1.25x
tier1_cap = 2.0
ot.overtime_tier1_hours = min(total, tier1_cap)
remaining = max(total - tier1_cap, 0)
# Tier 2: everything above 2 hours at 1.5x
ot.overtime_tier2_hours = remaining
ot.overtime_holiday_hours = 0 Some jurisdictions (California, France) calculate overtime on a daily basis — any hours over 8 in a day trigger overtime, regardless of weekly totals. Others (federal US FLSA) use a weekly threshold — overtime only kicks in after 40 hours in a week. Odoo 19's default is daily. If you need weekly calculation, override the _compute_overtime method to aggregate by ISO week instead of by day. Get this wrong and you owe back pay.
Wiring Attendance Overtime into Odoo 19 Payroll Work Entries
This is where everything comes together. Odoo 19's payroll module uses work entries as the bridge between time tracking and payslip computation. Every attendance record generates work entries, and overtime records generate additional entries with different work entry types — each mapped to a different pay rate.
Step 1: Create Work Entry Types for Overtime Tiers
<record id="work_entry_overtime_tier1"
model="hr.work.entry.type">
<field name="name">Overtime Tier 1 (1.25x)</field>
<field name="code">OT125</field>
<field name="is_leave">False</field>
<field name="sequence">30</field>
<field name="round_days">NO</field>
</record>
<record id="work_entry_overtime_tier2"
model="hr.work.entry.type">
<field name="name">Overtime Tier 2 (1.5x)</field>
<field name="code">OT150</field>
<field name="is_leave">False</field>
<field name="sequence">31</field>
<field name="round_days">NO</field>
</record>
<record id="work_entry_overtime_holiday"
model="hr.work.entry.type">
<field name="name">Holiday Overtime (2.0x)</field>
<field name="code">OT200</field>
<field name="is_leave">False</field>
<field name="sequence">32</field>
<field name="round_days">NO</field>
</record>Step 2: Generate Work Entries from Overtime Records
class HrAttendanceOvertime(models.Model):
_inherit = 'hr.attendance.overtime'
def _generate_work_entries(self):
"""Create payroll work entries from overtime tiers."""
WorkEntry = self.env['hr.work.entry']
entry_types = {
'tier1': self.env.ref(
'my_module.work_entry_overtime_tier1'
),
'tier2': self.env.ref(
'my_module.work_entry_overtime_tier2'
),
'holiday': self.env.ref(
'my_module.work_entry_overtime_holiday'
),
}
for ot in self.filtered(lambda o: o.duration > 0):
base_dt = fields.Datetime.from_string(
f"{{ot.date}} 17:00:00"
)
tiers = [
('tier1', ot.overtime_tier1_hours),
('tier2', ot.overtime_tier2_hours),
('holiday', ot.overtime_holiday_hours),
]
offset = 0.0
for tier_key, hours in tiers:
if hours <= 0:
continue
from datetime import timedelta
start = base_dt + timedelta(hours=offset)
end = start + timedelta(hours=hours)
WorkEntry.create({
'name': f"{{entry_types[tier_key].name}} - "
f"{{ot.employee_id.name}}",
'employee_id': ot.employee_id.id,
'work_entry_type_id': entry_types[tier_key].id,
'date_start': start,
'date_stop': end,
'duration': hours,
'state': 'draft',
})
offset += hoursStep 3: Map Work Entry Types to Salary Rules
In the payroll structure, create salary rules that reference the overtime work entry types:
<record id="salary_rule_ot_tier1" model="hr.salary.rule">
<field name="name">Overtime 1.25x</field>
<field name="code">OT125</field>
<field name="category_id"
ref="hr_payroll.ALW"/>
<field name="sequence">120</field>
<field name="condition_select">python</field>
<field name="condition_python">
result = worked_days.get('OT125') and worked_days['OT125'].number_of_hours > 0
</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
hourly_rate = contract.wage / 173.33
result = worked_days['OT125'].number_of_hours * hourly_rate * 1.25
</field>
</record>
<record id="salary_rule_ot_tier2" model="hr.salary.rule">
<field name="name">Overtime 1.5x</field>
<field name="code">OT150</field>
<field name="category_id"
ref="hr_payroll.ALW"/>
<field name="sequence">121</field>
<field name="condition_select">python</field>
<field name="condition_python">
result = worked_days.get('OT150') and worked_days['OT150'].number_of_hours > 0
</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
hourly_rate = contract.wage / 173.33
result = worked_days['OT150'].number_of_hours * hourly_rate * 1.5
</field>
</record>
<record id="salary_rule_ot_holiday" model="hr.salary.rule">
<field name="name">Holiday Overtime 2.0x</field>
<field name="code">OT200</field>
<field name="category_id"
ref="hr_payroll.ALW"/>
<field name="sequence">122</field>
<field name="condition_select">python</field>
<field name="condition_python">
result = worked_days.get('OT200') and worked_days['OT200'].number_of_hours > 0
</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">
hourly_rate = contract.wage / 173.33
result = worked_days['OT200'].number_of_hours * hourly_rate * 2.0
</field>
</record> The hourly rate divisor 173.33 comes from 40 hours/week × 52 weeks/year ÷ 12 months = 173.33 hours/month. This is the standard monthly-to-hourly conversion for a 40-hour work week. If your employees work different base hours, adjust this divisor accordingly. Using 160 (40 × 4) is a common error — it undercounts by 8% because months average 4.33 weeks, not 4.
4 Attendance Tracking Mistakes That Lead to Payroll Disputes and Compliance Fines
Timezone Mismatch Between Kiosk and Server
The kiosk sends the check-in timestamp in the browser's local timezone. Odoo stores it as UTC in the database. If your server, resource calendar, and kiosk are in different timezones, overtime calculation breaks silently. An employee checks out at 17:30 local time, but the server records 21:30 UTC, and the resource calendar says the shift ends at 17:00 in a third timezone. Result: phantom overtime that inflates payroll.
Set the resource calendar timezone to the physical location's timezone. Ensure the kiosk tablet's OS timezone matches. And in odoo.conf, never set a server-wide timezone override — let Odoo handle UTC conversion per user/resource.
Forgotten Check-Outs Destroying Overtime Data
An employee checks in at 08:00 and forgets to check out. The attendance record stays open. The next day, they check in again — Odoo auto-closes the previous record with a check-out time of the new check-in time. That's a 24-hour shift with 16 hours of overtime. Multiply this by 10 forgetful employees and your monthly overtime report looks like a hospital ER.
Implement a scheduled action that runs every night at 23:59 and auto-closes any attendance records open for more than 12 hours. Set the check-out to the scheduled end of the employee's shift from their resource calendar. Flag these records with a forced_checkout boolean for HR review.
Geofence Radius Too Tight for GPS Accuracy
A client set their geofence radius to 25 meters for an office building. Mobile GPS accuracy in urban areas with tall buildings averages 15-30 meters. Result: 40% of legitimate check-ins were flagged as violations, HR was buried in false alerts, and employees started ignoring the system entirely. Within two weeks, managers were overriding every violation, making the geofence meaningless.
Start with a 200-meter radius and analyze violation data for two weeks. If the false-positive rate is below 5%, tighten gradually. For indoor locations, use 300+ meters or skip geofencing entirely and rely on kiosk-based check-in instead.
Work Entries Not Regenerated After Attendance Corrections
HR corrects an attendance record (employee forgot to check out, wrong time, etc.). The attendance record updates, but the work entries generated from the original record remain unchanged. The payslip uses stale work entries and pays the wrong amount. The employee notices on payday. The dispute takes 3 days to resolve.
Override the write method on hr.attendance to regenerate work entries whenever check_in or check_out is modified. Delete the old work entries in draft state and create new ones from the corrected times. Never regenerate entries that are already in validated state — those are locked for payroll.
What Automated Attendance Tracking Saves Your Organization
Attendance automation isn't about surveillance — it's about eliminating the manual data pipeline between the time clock and the payslip. Here's what changes when you stop relying on spreadsheets and manual adjustments:
When overtime flows directly from attendance to payslip with auditable rules, employees trust the numbers. Disputes drop because the data is transparent and traceable.
No more manually exporting attendance, calculating overtime in spreadsheets, and importing into payroll. The pipeline is automated end-to-end.
Buddy punching elimination, accurate overtime tiering, and automatic rounding remove the phantom hours that inflate payroll. For a 200-employee company, that's $80K-$150K annually.
The compliance benefit is equally significant. Labor audits require attendance records with timestamps, location data, and overtime calculations. With Odoo 19's attendance stack, every data point is stored, timestamped, and linked to the payslip that consumed it. When the auditor asks "how did you calculate this employee's overtime for March?", the answer is a click — not a 3-day forensic reconstruction from spreadsheets.
Optimization Metadata
Set up Odoo 19 attendance tracking with kiosk mode, PIN/badge authentication, GPS geofencing, tiered overtime rules, and automatic payroll work entry integration.
1. "How to Set Up Odoo 19 Attendance Kiosk Mode for On-Site Check-In and Check-Out"
2. "PIN and Badge Authentication for Odoo 19 Attendance Kiosks"
3. "Enforcing Location-Based Check-In with Odoo 19 Geofencing"
4. "Configuring Overtime Rules and Automatic Calculation in Odoo 19"
5. "Wiring Attendance Overtime into Odoo 19 Payroll Work Entries"