diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py
index c6d62dfb..a04faf42 100644
--- a/fusion_clock/__manifest__.py
+++ b/fusion_clock/__manifest__.py
@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
- 'version': '19.0.3.11.6',
+ 'version': '19.0.3.11.7',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
diff --git a/fusion_clock/data/ir_cron_data.xml b/fusion_clock/data/ir_cron_data.xml
index 2a51456f..761455a3 100644
--- a/fusion_clock/data/ir_cron_data.xml
+++ b/fusion_clock/data/ir_cron_data.xml
@@ -61,4 +61,16 @@
80
+
+
+ Fusion Clock: Wipe Old Clock Photos
+
+ code
+ model._cron_fusion_wipe_old_photos()
+ 1
+ days
+ True
+ 65
+
+
diff --git a/fusion_clock/models/hr_attendance.py b/fusion_clock/models/hr_attendance.py
index 2419bee0..0f953cbd 100644
--- a/fusion_clock/models/hr_attendance.py
+++ b/fusion_clock/models/hr_attendance.py
@@ -341,6 +341,57 @@ class HrAttendance(models.Model):
att.id, str(e),
)
+ @api.model
+ def _cron_fusion_wipe_old_photos(self):
+ """Cron job: delete clock-in/out verification photos older than the
+ configured retention window (``fusion_clock.photo_retention_days``).
+
+ Only the images are removed — the attendance records, worked hours and
+ penalties are kept. The photos are attachment-backed binary fields, so we
+ unlink the underlying ir.attachment rows directly, which reclaims the
+ filestore space. Set the retention to 0 to disable the wipe entirely."""
+ ICP = self.env['ir.config_parameter'].sudo()
+ retention_days = int(ICP.get_param('fusion_clock.photo_retention_days', '60') or 0)
+ if retention_days <= 0:
+ return # 0 / unset → auto-wipe disabled
+
+ cutoff = fields.Datetime.now() - timedelta(days=retention_days)
+ old_attendances = self.sudo().search([('check_in', '<', cutoff)])
+ if not old_attendances:
+ return
+
+ Attachment = self.env['ir.attachment'].sudo()
+ photo_fields = [
+ 'x_fclk_check_in_photo', # NFC kiosk clock-in selfie
+ 'x_fclk_check_out_photo', # NFC kiosk clock-out selfie
+ 'x_fclk_checkin_photo', # legacy portal clock-in photo
+ ]
+ wiped = 0
+ # Batch the attendances so the res_id IN (...) list stays bounded, and
+ # isolate each batch in a savepoint so one bad row can't abort the rest.
+ for offset in range(0, len(old_attendances), 500):
+ batch_ids = old_attendances[offset:offset + 500].ids
+ photos = Attachment.search([
+ ('res_model', '=', 'hr.attendance'),
+ ('res_field', 'in', photo_fields),
+ ('res_id', 'in', batch_ids),
+ ])
+ if not photos:
+ continue
+ try:
+ with self.env.cr.savepoint():
+ count = len(photos)
+ photos.unlink()
+ wiped += count
+ except Exception as e:
+ _logger.error("Fusion Clock: Failed to wipe a photo batch: %s", e)
+
+ if wiped:
+ _logger.info(
+ "Fusion Clock: Wiped %s clock verification photo(s) older than %s days.",
+ wiped, retention_days,
+ )
+
@api.model
def _cron_fusion_check_absences(self):
"""Cron job: check for absent employees (no attendance on workday)."""
diff --git a/fusion_clock/models/res_config_settings.py b/fusion_clock/models/res_config_settings.py
index 3518f0da..df6b9fb5 100644
--- a/fusion_clock/models/res_config_settings.py
+++ b/fusion_clock/models/res_config_settings.py
@@ -268,6 +268,15 @@ class ResConfigSettings(models.TransientModel):
help="Which clock location is bound to the NFC kiosk for this company. "
"Required when the kiosk is enabled.",
)
+ fclk_photo_retention_days = fields.Integer(
+ string='Auto-Wipe Photos After (days)',
+ config_parameter='fusion_clock.photo_retention_days',
+ default=60,
+ help="Clock-in/out verification photos older than this many days are deleted "
+ "automatically by a daily cron. The attendance record, worked hours and "
+ "penalties are kept — only the images are removed, reclaiming storage. "
+ "Set to 0 to disable the auto-wipe.",
+ )
def set_values(self):
super().set_values()
diff --git a/fusion_clock/tests/__init__.py b/fusion_clock/tests/__init__.py
index dffa6faa..01994245 100644
--- a/fusion_clock/tests/__init__.py
+++ b/fusion_clock/tests/__init__.py
@@ -3,3 +3,4 @@
from . import test_nfc_models
from . import test_clock_nfc_kiosk
from . import test_shift_planner
+from . import test_photo_retention
diff --git a/fusion_clock/tests/test_photo_retention.py b/fusion_clock/tests/test_photo_retention.py
new file mode 100644
index 00000000..42d1ff56
--- /dev/null
+++ b/fusion_clock/tests/test_photo_retention.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Copyright 2026 Nexa Systems Inc.
+# License OPL-1 (Odoo Proprietary License v1.0)
+
+import base64
+from datetime import timedelta
+
+from odoo import fields
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+
+
+@tagged('-at_install', 'post_install', 'fusion_clock')
+class TestPhotoRetention(TransactionCase):
+ """The daily wipe cron must delete verification photos older than the
+ retention window while keeping the attendance records (and recent photos)."""
+
+ def setUp(self):
+ super().setUp()
+ self.Attendance = self.env['hr.attendance']
+ self.ICP = self.env['ir.config_parameter'].sudo()
+ self.emp = self.env['hr.employee'].create({'name': 'Retention Test Emp'})
+ # x_fclk_check_*_photo are plain Binary fields (no image validation),
+ # so arbitrary base64 bytes are fine for the test.
+ self.photo = base64.b64encode(b'pretend-jpeg-bytes')
+ self.ICP.set_param('fusion_clock.photo_retention_days', '60')
+
+ def _make_attendance(self, days_ago):
+ start = fields.Datetime.now() - timedelta(days=days_ago)
+ att = self.Attendance.create({
+ 'employee_id': self.emp.id,
+ 'check_in': start,
+ 'check_out': start + timedelta(hours=8),
+ })
+ att.x_fclk_check_in_photo = self.photo
+ att.x_fclk_check_out_photo = self.photo
+ return att
+
+ def test_old_photos_wiped_recent_kept(self):
+ old = self._make_attendance(days_ago=70)
+ recent = self._make_attendance(days_ago=5)
+ self.assertTrue(old.x_fclk_check_in_photo)
+ self.assertTrue(recent.x_fclk_check_in_photo)
+
+ self.Attendance._cron_fusion_wipe_old_photos()
+ self.env.invalidate_all() # binary fields are cached; re-read from DB
+
+ # Old shift: both photos gone, but the record + hours survive.
+ self.assertFalse(old.x_fclk_check_in_photo, "Old check-in photo should be wiped")
+ self.assertFalse(old.x_fclk_check_out_photo, "Old check-out photo should be wiped")
+ self.assertTrue(old.exists(), "Attendance record itself must be kept")
+ self.assertGreater(old.worked_hours, 0.0, "Worked hours must be preserved")
+
+ # Recent shift: photo untouched.
+ self.assertTrue(recent.x_fclk_check_in_photo, "Recent photo should be kept")
+
+ def test_zero_retention_disables_wipe(self):
+ self.ICP.set_param('fusion_clock.photo_retention_days', '0')
+ old = self._make_attendance(days_ago=200)
+
+ self.Attendance._cron_fusion_wipe_old_photos()
+ self.env.invalidate_all()
+
+ self.assertTrue(old.x_fclk_check_in_photo, "Retention=0 must disable the wipe")
diff --git a/fusion_clock/views/res_config_settings_views.xml b/fusion_clock/views/res_config_settings_views.xml
index 122a587c..f0bafb35 100644
--- a/fusion_clock/views/res_config_settings_views.xml
+++ b/fusion_clock/views/res_config_settings_views.xml
@@ -258,6 +258,10 @@
+
+
+
+