-- =====================================================================
--  Attendance & Payroll module — task seed
--  Generated from ATTENDANCE_PAYROLL_TASK_CARDS.md (28 July 2026)
--  70 tasks · 34 stories + 3 Employee-module adjustments · 292 points
--
--  Five oversized cards were split into a/b halves on 28 July 2026 so that
--  no task exceeds 8 points (16 hours):
--    1.4-BE  -> 1.4a-BE  single assignment  + 1.4b-BE  bulk assignment
--    5.3-BE  -> 5.3a-BE  leave approval     + 5.3b-BE  punch-voids-leave
--    8.2-BE  -> 8.2a-BE  payslip pipeline   + 8.2b-BE  advance + allocation
--    8.3-BE  -> 8.3a-BE  run approval       + 8.3b-BE  disbursement
--    8.3-FE  -> 8.3a-FE  approval screen    + 8.3b-FE  disbursement screens
--
--  ORDERING: 8.2b-BE MUST be merged before 8.3b-BE. 8.2a-BE writes a
--  provisional net_payable = net_pay; disbursing on that would pay a company
--  using salary advances the full net twice.
--
--  Each description carries a <h3>Menu</h3> block naming where the work shows
--  up in the sidebar; backend tasks name the frontend task they surface in.
--  Eighteen frontend tasks also carry a <h3>Actions</h3> table -- control,
--  when it is visible, which endpoint it calls, what the user sees, and the
--  one failure they will actually hit. That table is the QA script.
--
--  Target: pippa_task_manager.tasks
-- =====================================================================
--
--  CONFIGURED FOR:  project_id = 1,  project_module_id = 6 (all 64 tasks)
--
--  All four module variables point at module 6, so everything lands under
--  one module row. If you later split Attendance / Payroll / Core / Employee
--  into separate module rows, change the four @mod_* values below — nothing
--  else in the script needs touching.
--
--    @mod_attendance    40 tasks
--    @mod_payroll       26 tasks
--    @mod_core           1 task  (0.2-BE platform prerequisites)
--    @mod_employee       3 tasks (Part K)
--
--  @sprint_start is the first day of sprint 1; due dates are the last day
--  of each 2-week sprint. Set it to a Monday. The plan is 10 sprints.
--
--  @type_be / @type_fe must match the values your `type` column expects.
--
--  view_order continues from the current maximum for the project, so this
--  script cannot collide with tasks you already have.
--
--  estimate_hours = story points x 2  (1pt=2h, 2pt=4h, 3pt=6h, 5pt=10h, 8pt=16h)
--
--  Safe to re-run only after deleting the rows it created — branch_name is
--  unique per project, so a second run fails loudly rather than duplicating.
-- =====================================================================

SET NAMES utf8mb4;
SET @project_id     := 1;
SET @mod_attendance := 6;
SET @mod_payroll    := 6;
SET @mod_core       := 6;
SET @mod_employee   := 6;

SET @sprint_start   := '2026-08-03';  -- <<< CHANGE ME (Monday of sprint 1)
SET @type_be        := 'backend';     -- <<< CHANGE ME if your enum differs
SET @type_fe        := 'frontend';    -- <<< CHANGE ME if your enum differs

-- continue view_order after whatever already exists for this project
SET @vo := (SELECT COALESCE(MAX(`view_order`), 0) FROM `tasks` WHERE `project_id` = @project_id);

SET @s1 := DATE_ADD(@sprint_start, INTERVAL  13 DAY);
SET @s2 := DATE_ADD(@sprint_start, INTERVAL  27 DAY);
SET @s3 := DATE_ADD(@sprint_start, INTERVAL  41 DAY);
SET @s4 := DATE_ADD(@sprint_start, INTERVAL  55 DAY);
SET @s5 := DATE_ADD(@sprint_start, INTERVAL  69 DAY);
SET @s6 := DATE_ADD(@sprint_start, INTERVAL  83 DAY);
SET @s7 := DATE_ADD(@sprint_start, INTERVAL  97 DAY);
SET @s8 := DATE_ADD(@sprint_start, INTERVAL 111 DAY);
SET @s9 := DATE_ADD(@sprint_start, INTERVAL 125 DAY);
SET @s10 := DATE_ADD(@sprint_start, INTERVAL 139 DAY);

SET @now := NOW();

START TRANSACTION;

-- =====================================================================
--  PART 0 — BOOTSTRAP  (4 tasks, 12 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '0.1-BE Module Scaffolding — Backend',
 'att-0-1-be-module-scaffolding',
 @vo + 1, @type_be,
 '<h3>Summary</h3><p>As a developer, I want both nwidart modules scaffolded and routed so that every later story has a place to put its code.</p>
<h3>Start after</h3><p>Nothing — this task can start immediately</p>
<h3>Permission</h3><p>n/a (infrastructure)</p>
<h3>Menu</h3><p>none — API only; surfaces in 0.1-FE</p>
<h3>Commands</h3><pre>docker compose exec backend php artisan module:make Attendance
docker compose exec backend php artisan module:enable Attendance
docker compose exec backend php artisan module:make Payroll
docker compose exec backend php artisan module:enable Payroll</pre>
<h3>Business rules</h3>
<ul>
<li>Each module RouteServiceProvider::mapApiRoutes() sets the prefix and route-name prefix.</li>
<li>Attendance: prefix <code>api/v1/attendance</code>, name <code>api.attendance.</code></li>
<li>Payroll: prefix <code>api/v1/payroll</code>, name <code>api.payroll.</code></li>
<li>Mirror <code>Modules/Employee/app/Providers/RouteServiceProvider.php</code> exactly. This is the only place the URL shape is defined and every endpoint in the plan assumes it.</li>
<li>Add <code>config/config.php</code> per module, modelled on the Employee module config. Attendance: <code>correction_window_days</code> = 30, <code>bulk_assignment_queue_threshold</code> = 200, <code>export_queue_threshold</code> = 5000. Payroll: <code>payslip_generation_chunk_size</code> = 100.</li>
<li>Register each module service provider bindings file (contracts to implementations) following EmployeeServiceProvider.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li><code>php artisan module:list</code> shows both modules enabled.</li>
<li><code>modules_statuses.json</code> contains Attendance true and Payroll true.</li>
<li>Temporary <code>GET /api/v1/attendance/health</code> and <code>GET /api/v1/payroll/health</code> return 200 behind auth:api.</li>
<li><code>php artisan route:list</code> shows both route-name prefixes.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD: layering (Controller to Service to Repository), Form Request plus API Resource, tenancy in the repository, feature and unit tests, activity log, api collection yml, code reviewed and merged.</li>
<li>Module config files committed with the tunables above.</li>
<li>Temporary health routes retained, mirroring the Configuration module /health.</li>
</ul>',
 6.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '0.1-FE Module Shell — Frontend',
 'att-0-1-fe-module-shell',
 @vo + 2, @type_fe,
 '<h3>Summary</h3><p>As a developer, I want both frontend modules registered with their navigation groups so later screens plug in without touching core code.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>attendance.menu-view</code>, <code>payroll.menu-view</code></p>
<h3>Menu</h3><p>creates both nav groups — <strong>Attendance</strong> and <strong>Payroll</strong> — plus their landing pages</p>
<h3>Frontend routes</h3><pre>/attendance
/attendance/config
/payroll</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Attendance landing</strong> — placeholder page listing the module sections; each entry appears only if the user holds its permission.</li>
<li><strong>Payroll landing</strong> — same pattern.</li>
<li><strong>Navigation groups</strong> — two new collapsible groups in <code>modules/core/config/navigation.ts</code>:</li>
</ul>
<pre>Attendance   (icon bi-clock-history, permission attendance.menu-view)
  Configuration, Punches, Attendance Records, Corrections, Leave, Monthly Approval

Payroll      (icon bi-cash-stack, permission payroll.menu-view)
  Settings, Salary Structures, Tax Slabs, Deductions, Payroll Runs, Payslips, Disbursements</pre>
<p><strong>Decision R1:</strong> attendance types, shifts, policies and assignments all live behind the <em>single</em> Configuration entry — one submenu with four sections, not four separate nav items. The Configuration module is not involved.</p>
<h3>API integration</h3><pre>GET /api/v1/attendance/health
GET /api/v1/payroll/health</pre>
<h3>UI rules</h3>
<ul>
<li>Routes wrapped ProtectedRoute to AppLayout to RequirePermissionRoute, matching <code>modules/employee/index.tsx</code>.</li>
<li>A nav group renders only when the user holds its parent permission; empty groups are not shown.</li>
<li>Module ids are <code>attendance</code> and <code>payroll</code>; version 1.0.0.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Both modules are registered in registerModules.ts and their landing pages render for a permitted user.</li>
<li>A user lacking payroll.menu-view sees no Payroll group and is redirected away from /payroll.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD: API layer under modules/&lt;module&gt;/api, TanStack Query for server state, loading/empty/error states, validation mirroring backend, responsive desktop and tablet, code reviewed and merged.</li>
<li><code>modules/attendance/index.tsx</code> and <code>modules/payroll/index.tsx</code> created and registered.</li>
<li>Nav groups added with correct permission keys.</li>
</ul>',
 6.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_core,
 '0.2-BE Platform Prerequisites — Backend',
 'core-0-2-be-platform-prerequisites',
 @vo + 3, @type_be,
 '<h3>Summary</h3><p>As the system, I need a company timezone and a working queue so attendance days and background jobs behave correctly.</p>
<h3>Start after</h3><p>Nothing — this task can start immediately</p>
<h3>Permission</h3><p>n/a (infrastructure)</p>
<h3>Menu</h3><p><strong>Admin › Companies</strong> — one field added to the existing company form, no new screen</p>
<h3>Related tables</h3><ul><li><code>companies</code> (modified)</li></ul>
<h3>DB schema — companies, new column</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>timezone</td><td>varchar(64), default Asia/Dhaka</td><td>IANA timezone name; the fallback for shifts that do not set their own</td></tr>
</table>
<h3>Business rules</h3>
<ul>
<li>All punch_time values are stored in UTC. attendance_date, shift boundaries and the nightly job previous day are resolved in the effective timezone: <code>shifts.timezone ?? companies.timezone</code>.</li>
<li>Migration backfills existing rows with Asia/Dhaka.</li>
<li>Confirm queue configuration end to end — Parts C, E and H all dispatch jobs, following the existing ProcessEmployeeImportJob pattern.</li>
</ul>
<h3>Validation</h3>
<ul><li>timezone must be a valid IANA identifier (timezone_identifiers_list()).</li></ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Every existing company row has a non-null timezone after migration.</li>
<li>A test job dispatched to the queue is processed by the worker.</li>
<li>A unit test proves a 23:30 UTC punch maps to the correct local attendance_date for Asia/Dhaka.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Company update endpoint and admin UI accept the new field — a small addition to the existing screen, no new screen.</li>
</ul>',
 4.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '0.3-BE Action & Permission Registry — Backend',
 'att-0-3-be-permission-registry',
 @vo + 4, @type_be,
 '<h3>Summary</h3><p>As an administrator, I want every Attendance and Payroll permission to exist in the platform registry so routes can be guarded and roles configured from day one.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Permission</h3><p>n/a (infrastructure)</p>
<h3>Menu</h3><p><strong>Admin › Roles</strong> — configured through the existing Actions / Modules / Roles UI, no new screen</p>
<h3>Related tables</h3>
<ul><li>modules, actions, module_actions, permissions, roles, role_permissions</li></ul>
<h3>Seeders</h3>
<ul>
<li><code>AttendanceModuleSeeder</code> — creates the attendance module row (route_prefix /attendance, api_prefix /attendance, sort_order 300) and its module_actions.</li>
<li><code>PayrollModuleSeeder</code> — same for payroll (sort_order 400).</li>
<li><code>AttendancePayrollRoleSeeder</code> — grants the self-service subset to the seeded employee role.</li>
<li>Both module seeders call <code>PermissionRepositoryInterface::syncForModule($module)</code> at the end. Model them on <code>database/seeders/EmployeeModuleSeeder.php</code>.</li>
</ul>
<h3>Permission keys</h3>
<p><strong>attendance.*</strong> — menu-view, config-manage, assignment-manage, assignment-preview, punch-create, punch-create-others, record-view-own, record-view-team, record-view-all, record-export, record-recalculate, correction-create, correction-approve, correction-override-lock, leave-apply, leave-approve, leave-balance-view, leave-balance-adjust, monthly-view, monthly-approve, monthly-unlock</p>
<p><strong>payroll.*</strong> — menu-view, settings-manage, structure-manage, deduction-manage, advance-manage, advance-approve, payment-mode-manage, run-create, run-override-readiness, run-approve, month-freeze, month-unfreeze-paid, disburse, payslip-view-own, payslip-view-all</p>
<h3>Business rules</h3>
<ul>
<li>Reuse an existing actions.slug where one fits; create new actions rows only for genuinely new verbs.</li>
<li>module_actions.permission_key is always <code>&lt;module_slug&gt;.&lt;action_slug&gt;</code>.</li>
<li>The five approvable actions (attendance.correction-approve, attendance.leave-approve, attendance.monthly-approve, payroll.run-approve, payroll.advance-approve) are seeded with requires_approval = true. Wiring them to workflows is task 4.1-BE.</li>
<li>Self-service keys granted to the employee role: attendance.menu-view, punch-create, record-view-own, correction-create, leave-apply, payroll.menu-view, payroll.payslip-view-own.</li>
<li><strong>Segregation of duties:</strong> no seeded role receives both attendance.monthly-approve and payroll.month-freeze.</li>
<li>All seeders are idempotent (updateOrCreate) and safe to re-run.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>After migrate --seed, every permission key listed above exists in the permissions table.</li>
<li>Re-running the seeders produces no duplicate rows and no changed ids.</li>
<li>A user without a given key receives 403 from a route guarded by it.</li>
<li>No seeded role holds both approve and freeze permissions.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Seeders registered in DatabaseSeeder.</li>
<li>Roles configurable through the existing Admin UI (Actions, Modules, Roles) with no new screen.</li>
</ul>',
 8.00, 'todo', 'high', @s1, NULL, NULL, @now, @now);

-- =====================================================================
--  PART A — CONFIGURATION  (9 tasks, 37 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '1.1-BE Attendance Types — Backend',
 'att-1-1-be-attendance-types',
 @vo + 5, @type_be,
 '<h3>Summary</h3><p>As HR, I want to create and manage Attendance Types so daily attendance is categorised consistently, and so the calculation engine resolves statuses by a stable code rather than by name.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>attendance.config-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p>none — API only; surfaces in 1.1-FE</p>
<h3>Related tables</h3><ul><li><code>attendance_types</code> (new)</li></ul>
<h3>DB schema — attendance_types</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>name</td><td>varchar(100)</td><td>Display name (Present, Late)</td></tr>
<tr><td>code</td><td>varchar(50)</td><td>Unique within company</td></tr>
<tr><td>system_code</td><td>varchar(50) nullable</td><td><strong>Immutable.</strong> present, absent, late, half_day, leave, holiday, weekend, wfh, business_trip, missing_check_in, missing_check_out. NULL for HR-created types</td></tr>
<tr><td>category</td><td>varchar(50)</td><td>Grouping label for UI</td></tr>
<tr><td>is_paid</td><td>boolean</td><td>Whether this status is paid</td></tr>
<tr><td>counts_as_working_day</td><td>boolean</td><td>Working-day flag</td></tr>
<tr><td>eligible_for_payroll</td><td>boolean</td><td>Included in payroll calculation</td></tr>
<tr><td>color</td><td>varchar(30)</td><td>UI colour</td></tr>
<tr><td>icon</td><td>varchar(50)</td><td>UI icon</td></tr>
<tr><td>is_system</td><td>boolean default false</td><td>Seeded row; blocks delete</td></tr>
<tr><td>status</td><td>varchar(20) default Active</td><td>Active / Inactive</td></tr>
<tr><td>created_by, updated_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
<tr><td>created_at, updated_at</td><td>timestamp</td><td>Laravel timestamps</td></tr>
</table>
<p>Keys: unique(company_id, code) · unique(company_id, system_code) · index(company_id, status)</p>
<h3>API endpoints</h3>
<pre>GET    /api/v1/attendance/attendance-types
POST   /api/v1/attendance/attendance-types
GET    /api/v1/attendance/attendance-types/{id}
PUT    /api/v1/attendance/attendance-types/{id}
PATCH  /api/v1/attendance/attendance-types/{id}/status
DELETE /api/v1/attendance/attendance-types/{id}</pre>
<h3>Business rules</h3>
<ul>
<li>HR can create attendance types with name, code, category, the three flags, colour and icon.</li>
<li>name and code are each unique within a company.</li>
<li>system_code is set only by the seeder (task 9.3-BE) and can never be changed through the API. HR may rename, recolour or deactivate a system type but not re-map it.</li>
<li>The calculation engine (task 3.2-BE) resolves statuses by system_code, never by name or id.</li>
<li>Rows with is_system = true cannot be deleted, only deactivated.</li>
<li>A type referenced by any attendance_records row cannot be deleted.</li>
<li>Inactive types are not selectable for new records but remain visible in historical data and reports.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>name, code — required, unique per company_id.</li>
<li>code — uppercase alphanumeric plus underscore, max 50.</li>
<li>system_code — rejected outright if present in a create or update request body.</li>
<li>is_paid, counts_as_working_day, eligible_for_payroll — required booleans.</li>
<li>color — valid hex or Bootstrap variant name.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Duplicate name or code within company — 409 Conflict</li>
<li>Unknown id — 404 Not Found</li>
<li>Missing permission — 403 Forbidden</li>
<li>Invalid or missing fields — 422 with field-level messages</li>
<li>Delete on is_system = true — 409, message names the constraint</li>
<li>Delete on a referenced type — 409, response body includes the referencing record count</li>
<li>system_code present in request body — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can create a new attendance type with all mandatory fields validated.</li>
<li>Duplicate name or code within the same company is rejected with 409.</li>
<li>A seeded system type cannot be deleted, and its system_code is unchanged after an update request that tries to include one.</li>
<li>Deactivating a type removes it from selectable options everywhere but leaves historical records readable.</li>
<li>A type used by at least one attendance record cannot be deleted.</li>
<li>Queries return only the caller company rows, verified by a cross-tenant test.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration with all three keys.</li>
<li>Unit tests covering system_code immutability and both delete-guard paths.</li>
<li>api collection/Attendance/Attendance Types/*.yml</li>
</ul>',
 6.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.1-FE Attendance Types — Frontend',
 'att-1-1-fe-attendance-types',
 @vo + 6, @type_fe,
 '<h3>Summary</h3><p>As HR, I want a single screen to manage Attendance Types with a visual preview of each type colour and icon, so setup mistakes are obvious before they reach attendance records.</p>
<h3>Start after</h3><p>1.1-BE Attendance Types · 0.1-FE Module Shell</p>
<h3>Permission</h3><p><code>attendance.config-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p><strong>Attendance › Configuration › Attendance Types</strong></p>
<h3>Related tables</h3><ul><li><code>attendance_types</code> — see task 1.1-BE for the schema.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/config/attendance-types</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Attendance Type List</strong> — colour/icon preview chip per row, status badge, System tag on is_system rows. Filterable by status and category.</li>
<li><strong>Add / Edit modal</strong> — name, code, category, colour picker, icon picker, three boolean toggles. On a system type, code is read-only and delete is absent.</li>
<li><strong>Status toggle</strong> — inline Active/Inactive switch, optimistic update with rollback on error.</li>
<li><strong>Delete confirmation</strong> — shows the referencing-record count from the 409 body instead of a generic failure.</li>
</ul>
<h3>API integration</h3>
<pre>GET    /api/v1/attendance/attendance-types
POST   /api/v1/attendance/attendance-types
PUT    /api/v1/attendance/attendance-types/{id}
PATCH  /api/v1/attendance/attendance-types/{id}/status
DELETE /api/v1/attendance/attendance-types/{id}</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Add type</strong></td><td><code>attendance.config-manage</code></td><td><code>POST /attendance-types</code></td><td>Row appears in list with its colour chip</td><td>409 duplicate name or code → field error</td></tr>
<tr><td>2</td><td><strong>Edit</strong></td><td><code>config-manage</code></td><td><code>PUT /attendance-types/{id}</code></td><td>Row updates in place</td><td>On a system type, <code>code</code> is read-only with a tooltip</td></tr>
<tr><td>3</td><td><strong>Toggle status</strong></td><td><code>config-manage</code></td><td><code>PATCH /{id}/status</code></td><td>Optimistic switch, rolls back on error</td><td>—</td></tr>
<tr><td>4</td><td><strong>Delete</strong></td><td><code>config-manage</code> <strong>and</strong> <code>is_system = false</code></td><td><code>DELETE /{id}</code></td><td>Row removed</td><td>409 referenced → dialog shows the referencing record count</td></tr>
<tr><td>5</td><td>Colour / icon pick</td><td>in the form</td><td>—</td><td>Live preview matching the list chip</td><td>—</td></tr>
</table>
<p><strong>Not offered:</strong> Delete on a system type — the button is <strong>absent</strong>, not disabled, because a seeded type can never be deleted and a disabled control invites repeated attempts.</p>
<h3>UI rules</h3>
<ul>
<li>Delete button hidden entirely on is_system rows — not shown-and-disabled.</li>
<li>code disabled in edit mode for system types, with a tooltip explaining why.</li>
<li>Colour and icon render live in the form as the user picks them, matching the list chip.</li>
<li>Users without attendance.config-manage see a read-only list; mutating controls are not rendered.</li>
<li>A 409 on delete surfaces the server message inline in the confirmation dialog, not as a toast.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can create, edit, activate and deactivate types without leaving the screen.</li>
<li>A system type visibly cannot be deleted or re-coded.</li>
<li>Deleting a referenced type shows how many records reference it.</li>
<li>A read-only user sees the list and no mutating controls.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/attendanceTypeApi.ts</code> with TanStack Query hooks.</li>
<li>Nav entry under Attendance, Configuration section.</li>
</ul>',
 6.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.2-BE Shift Management — Backend',
 'att-1-2-be-shifts',
 @vo + 7, @type_be,
 '<h3>Summary</h3><p>As HR, I want to define working shifts so check-in/out and status calculation follow the correct schedule, including overnight shifts.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.2-BE Platform Prerequisites · 0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>attendance.config-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p>none — API only; surfaces in 1.2-FE</p>
<h3>Related tables</h3><ul><li><code>shifts</code> (new)</li></ul>
<h3>DB schema — shifts</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>name</td><td>varchar(150)</td><td>Shift name</td></tr>
<tr><td>code</td><td>varchar(50)</td><td>Unique within company</td></tr>
<tr><td>start_time, end_time</td><td>time</td><td>Shift schedule</td></tr>
<tr><td>timezone</td><td>varchar(64) nullable</td><td>IANA name; falls back to companies.timezone</td></tr>
<tr><td>break_minutes</td><td>int default 0</td><td>Unpaid break duration</td></tr>
<tr><td>working_hours</td><td>decimal(5,2)</td><td>Expected paid hours</td></tr>
<tr><td>grace_minutes</td><td>int default 0</td><td>Late-arrival grace period</td></tr>
<tr><td>min_hours_present</td><td>decimal(5,2)</td><td>Minimum hours for Present</td></tr>
<tr><td>min_hours_half_day</td><td>decimal(5,2)</td><td>Minimum hours for Half Day</td></tr>
<tr><td>working_days</td><td>json</td><td>ISO-8601 day numbers, 1 = Monday, e.g. [7,1,2,3,4]</td></tr>
<tr><td>is_overnight</td><td>boolean default false</td><td>Crosses midnight</td></tr>
<tr><td>status</td><td>varchar(20) default Active</td><td>Active / Inactive</td></tr>
<tr><td>created_by, updated_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
</table>
<p>Keys: unique(company_id, code) · index(company_id, status)</p>
<h3>API endpoints</h3>
<pre>GET    /api/v1/attendance/shifts
POST   /api/v1/attendance/shifts
GET    /api/v1/attendance/shifts/{id}
PUT    /api/v1/attendance/shifts/{id}
PATCH  /api/v1/attendance/shifts/{id}/status
DELETE /api/v1/attendance/shifts/{id}</pre>
<h3>Business rules</h3>
<ul>
<li>HR defines name, code, start/end time, break, grace period, working days, overnight flag and the two minimum-hours thresholds.</li>
<li>name and code are each unique within a company.</li>
<li>Deactivated shifts remain visible for historical reporting but cannot be newly assigned (enforced in task 1.4a-BE).</li>
<li>A shift referenced by an active assignment cannot be deleted; a shift referenced by any attendance_records row cannot be deleted at all.</li>
<li>timezone left null means the company timezone applies. This is the value snapshotted into policy_snapshot (task 2.2-BE).</li>
</ul>
<h3>Validation</h3>
<ul>
<li>end_time must be later than start_time unless is_overnight = true.</li>
<li>min_hours_half_day at most min_hours_present, and min_hours_present at most working_hours.</li>
<li>working_days — non-empty array of unique integers 1 to 7.</li>
<li>working_hours — greater than 0 and at most 24.</li>
<li>grace_minutes, break_minutes — non-negative integers.</li>
<li>timezone — valid IANA identifier when present.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>end_time not after start_time without the overnight flag — 422 with a message naming both fields</li>
<li>Delete on a shift with an active assignment — 409, response lists the assignment count</li>
<li>Unknown id — 404 · Missing permission — 403 · Invalid fields — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can create a shift with valid start/end times, including an overnight shift flagged correctly.</li>
<li>The system rejects an end time that is not after the start time unless the shift is marked overnight.</li>
<li>Threshold ordering is enforced — a shift with min_hours_present above working_hours is rejected.</li>
<li>Deactivated shifts remain readable in historical reports but are absent from assignment pickers.</li>
<li>A shift in use cannot be deleted.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Unit tests for overnight validation and threshold ordering.</li>
<li>api collection/Attendance/Shifts/*.yml</li>
</ul>',
 10.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.2-FE Shift Management — Frontend',
 'att-1-2-fe-shifts',
 @vo + 8, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to build and review shifts on one screen, with the working week and overnight behaviour visible at a glance.</p>
<h3>Start after</h3><p>1.2-BE Shifts</p>
<h3>Permission</h3><p><code>attendance.config-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p><strong>Attendance › Configuration › Shifts</strong></p>
<h3>Related tables</h3><ul><li><code>shifts</code> — see task 1.2-BE for the schema.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/config/shifts</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Shift List</strong> — start/end time, a compact working-days summary (Sun to Thu), an overnight badge, and status.</li>
<li><strong>Add / Edit form</strong> — time pickers, working-days checkbox row, break/grace/threshold numeric fields, overnight toggle, optional timezone select.</li>
<li><strong>Status toggle</strong> — inline Active/Inactive.</li>
<li><strong>Delete action</strong> — blocked with an explanatory dialog when the shift is assigned.</li>
</ul>
<h3>API integration</h3>
<pre>GET    /api/v1/attendance/shifts
POST   /api/v1/attendance/shifts
GET    /api/v1/attendance/shifts/{id}
PUT    /api/v1/attendance/shifts/{id}
PATCH  /api/v1/attendance/shifts/{id}/status
DELETE /api/v1/attendance/shifts/{id}</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Add shift</strong></td><td><code>attendance.config-manage</code></td><td><code>POST /shifts</code></td><td>Row appears with working-days summary</td><td>422 threshold ordering → inline errors before submit</td></tr>
<tr><td>2</td><td>Pick end time earlier than start</td><td>in the form</td><td>—</td><td><strong>Overnight toggle flips automatically</strong> with a visible hint; user may override</td><td>—</td></tr>
<tr><td>3</td><td><strong>Edit</strong></td><td><code>config-manage</code></td><td><code>PUT /shifts/{id}</code></td><td>Row updates</td><td>422 <code>end_time</code> not after <code>start_time</code> without the overnight flag</td></tr>
<tr><td>4</td><td><strong>Toggle status</strong></td><td><code>config-manage</code></td><td><code>PATCH /{id}/status</code></td><td>Inline switch</td><td>—</td></tr>
<tr><td>5</td><td><strong>Delete</strong></td><td><code>config-manage</code></td><td><code>DELETE /{id}</code></td><td>Row removed</td><td>409 assigned → dialog lists the assignment count</td></tr>
<tr><td>6</td><td>Change times / break</td><td>in the form</td><td>—</td><td>Live "expected span minus break" line so an inconsistent <code>working_hours</code> is visible</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>Overnight toggle flips automatically, with a visible hint, when the user picks an end time earlier than the start time; the user can override.</li>
<li>A live expected-span line under the time pickers shows the computed duration minus break, so an inconsistent working_hours is obvious before saving.</li>
<li>Threshold fields show inline errors as soon as the ordering rule breaks, without waiting for submit.</li>
<li>Working-days row renders in the company week order, not always Monday-first.</li>
<li>Timezone select defaults to Use company timezone rather than pre-filling a value.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can create an overnight shift and see it badged as such in the list.</li>
<li>Invalid threshold ordering is surfaced before submit.</li>
<li>Attempting to delete an assigned shift explains why it is blocked.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/shiftApi.ts</code></li>
<li>Nav entry under Attendance, Configuration section.</li>
</ul>',
 6.00, 'todo', 'high', @s1, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.3-BE Policy Configuration (Leave & Holiday) — Backend',
 'att-1-3-be-policies',
 @vo + 9, @type_be,
 '<h3>Summary</h3><p>As HR, I want to configure Leave and Holiday policies through one consistent model so both share the same setup, status and assignment behaviour.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>attendance.config-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p>none — API only; surfaces in 1.3-FE</p>
<h3>Related tables</h3><ul><li><code>policies</code> (new)</li></ul>
<h3>DB schema — policies</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>policy_type</td><td>enum(leave, holiday)</td><td>Which family of rules config holds</td></tr>
<tr><td>name</td><td>varchar(150)</td><td>Policy name</td></tr>
<tr><td>code</td><td>varchar(50)</td><td>Unique within company <strong>per policy_type</strong></td></tr>
<tr><td>effective_date</td><td>date</td><td>Policy start date</td></tr>
<tr><td>config</td><td>json</td><td>Type-specific rules</td></tr>
<tr><td>status</td><td>enum(Active, Inactive, Archived)</td><td>Lifecycle</td></tr>
<tr><td>created_by, updated_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
</table>
<p>Keys: unique(company_id, policy_type, code) · index(company_id, policy_type, status)</p>
<h4>config when policy_type = leave</h4>
<pre>{
  "accrual_method": "annual|monthly|on_joining",
  "entitlement_days": 20,
  "carry_forward_allowed": true,
  "max_carry_forward": 5,
  "encashment_allowed": false,
  "half_day_allowed": true,
  "lwp_allowed": true,
  "advance_notice_days": 3,
  "backdate_limit_days": 7,
  "document_required_after_days": 3
}</pre>
<h4>config when policy_type = holiday</h4>
<pre>{
  "holidays": [
    { "name": "Victory Day", "date": "2026-12-16", "type": "public",
      "recurring": true, "description": "" }
  ]
}</pre>
<h3>API endpoints</h3>
<pre>GET    /api/v1/attendance/policies?type=leave|holiday
POST   /api/v1/attendance/policies
GET    /api/v1/attendance/policies/{id}
PUT    /api/v1/attendance/policies/{id}
PATCH  /api/v1/attendance/policies/{id}/status
DELETE /api/v1/attendance/policies/{id}</pre>
<h3>Business rules</h3>
<ul>
<li>A policy is created with policy_type, name, code, effective date, status and a config object matching its type.</li>
<li>config is validated against the type-specific schema — an unknown key is rejected rather than silently stored.</li>
<li>name and code are each unique within a company <strong>per policy_type</strong>, so a leave policy and a holiday policy may share a code.</li>
<li>Only Active policies are selectable during assignment (task 1.4a-BE).</li>
<li>Inactive policies remain readable for historical reporting; Archived policies are hidden from all pickers and default list views.</li>
<li>A policy referenced by an assignment, a leave request or a leave balance cannot be deleted — deactivate or archive instead.</li>
<li>Recurring holidays repeat on the same month and day each year; the resolver (task 2.1-BE) expands them for the requested year.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>policy_type, name, code, effective_date, config — required.</li>
<li>Leave config: entitlement_days greater than 0; max_carry_forward required and greater than 0 when carry_forward_allowed is true; advance_notice_days and backdate_limit_days non-negative; accrual_method within the enum.</li>
<li>Holiday config: holidays non-empty; each entry needs name and a valid date; no two entries share the same date within one policy.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Duplicate code within (company, policy_type) — 409</li>
<li>config shape not matching policy_type — 422, errors keyed by config.&lt;field&gt;</li>
<li>Delete on a referenced policy — 409, response names the referencing entity type and count</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can create a Leave policy and a Holiday policy through the same endpoint with type-specific validation applied to each.</li>
<li>Duplicate codes within the same type and company are rejected; the same code in the other type is accepted.</li>
<li>A leave policy with carry_forward_allowed but no max_carry_forward is rejected with a field-level error.</li>
<li>Only Active policies appear in assignment pickers; Inactive ones remain in historical reports.</li>
<li>A policy in use cannot be deleted.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>config validated by dedicated rule objects per policy type, unit-tested for both shapes.</li>
<li>api collection/Attendance/Policies/*.yml</li>
</ul>',
 10.00, 'todo', 'high', @s2, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.3-FE Policy Configuration (Leave & Holiday) — Frontend',
 'att-1-3-fe-policies',
 @vo + 10, @type_fe,
 '<h3>Summary</h3><p>As HR, I want one policy screen whose form adapts to the selected policy type, so leave rules and holiday calendars are managed the same way.</p>
<h3>Start after</h3><p>1.3-BE Policies</p>
<h3>Permission</h3><p><code>attendance.config-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p><strong>Attendance › Configuration › Policies</strong></p>
<h3>Related tables</h3><ul><li><code>policies</code> — see task 1.3-BE for the schema.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/config/policies
/attendance/config/policies/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Policy List</strong> — tabs or a filter for Leave / Holiday, showing name, code, effective date and status.</li>
<li><strong>Add / Edit form</strong> — shared header fields, then a type-driven body: the leave rule set, or the holiday list builder.</li>
<li><strong>Holiday list builder</strong> — repeatable rows (name, date, type, recurring, description) with add/remove and date-sorted display; shown only for holiday policies.</li>
<li><strong>Leave rule panel</strong> — accrual method, entitlement, carry-forward group, encashment, half-day, LWP, notice and backdate limits, document requirement.</li>
<li><strong>Status actions</strong> — Activate / Deactivate / Archive.</li>
</ul>
<h3>API integration</h3>
<pre>GET    /api/v1/attendance/policies?type=leave|holiday
POST   /api/v1/attendance/policies
GET    /api/v1/attendance/policies/{id}
PUT    /api/v1/attendance/policies/{id}
PATCH  /api/v1/attendance/policies/{id}/status</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Add policy</strong></td><td><code>attendance.config-manage</code></td><td><code>POST /policies</code></td><td>Row in the matching type tab</td><td>409 duplicate code within the same <code>policy_type</code></td></tr>
<tr><td>2</td><td>Pick <code>policy_type</code></td><td><strong>create only</strong></td><td>—</td><td>Form body swaps between leave rules and the holiday builder</td><td>Locked after creation — switching would invalidate the whole <code>config</code></td></tr>
<tr><td>3</td><td>Toggle <code>carry_forward_allowed</code></td><td>leave policies</td><td>—</td><td><code>max_carry_forward</code> appears and becomes required</td><td>422 if left empty</td></tr>
<tr><td>4</td><td><strong>Add holiday row</strong></td><td>holiday policies</td><td>—</td><td>New repeatable row</td><td>Duplicate date blocked client-side, conflicting row highlighted</td></tr>
<tr><td>5</td><td><strong>Save</strong></td><td><code>config-manage</code></td><td><code>PUT /policies/{id}</code></td><td>Policy updated</td><td>422 errors map back to the offending repeatable row, not the form root</td></tr>
<tr><td>6</td><td><strong>Activate / Deactivate</strong></td><td><code>config-manage</code></td><td><code>PATCH /{id}/status</code></td><td>Status badge changes</td><td>—</td></tr>
<tr><td>7</td><td><strong>Archive</strong></td><td><code>config-manage</code></td><td><code>PATCH /{id}/status</code></td><td>Disappears from default list and all pickers</td><td>Confirmation states this explicitly</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>policy_type is chosen once at creation and locked thereafter — switching type would invalidate the whole config.</li>
<li>max_carry_forward is hidden until carry_forward_allowed is on, and required once visible.</li>
<li>The holiday builder blocks a duplicate date client-side and highlights the conflicting row.</li>
<li>Recurring holidays show a repeats-yearly marker so a one-off is visibly different.</li>
<li>Archive is separated from Deactivate with a confirmation explaining that archived policies disappear from all pickers.</li>
<li>Field-level errors from the 422 body map back onto the correct repeatable row, not to the form root.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR creates both policy types from one screen with the correct fields appearing per type.</li>
<li>Duplicate holiday dates are caught before submit.</li>
<li>Carry-forward cap cannot be left empty once carry-forward is enabled.</li>
<li>Archived policies disappear from the default list and from assignment pickers.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/policyApi.ts</code></li>
<li>Nav entry under Attendance, Configuration section.</li>
</ul>',
 10.00, 'todo', 'high', @s2, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.4a-BE Shift & Policy Assignment — Backend',
 'att-1-4a-be-assignments',
 @vo + 11, @type_be,
 '<h3>Summary</h3><p>As HR, I want to assign shifts, leave policies and holiday policies to an employee or to an organisation unit, with full history preserved.</p>
<p><strong>Split note.</strong> Split from the original 8-point 1.4-BE. This task owns the table and single-target assignment; <strong>1.4b-BE</strong> adds bulk assignment. Both halves ship independently — HR can assign one at a time before bulk exists.</p>
<h3>Start after</h3><p>1.2-BE Shifts · 1.3-BE Policies</p>
<h3>Also needs (can be stubbed)</h3><p>1.1-BE Attendance Types</p>
<h3>Permission</h3><p><code>attendance.assignment-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p>none — API only; surfaces in 1.4-FE</p>
<h3>Related tables</h3><ul><li><code>assignments</code> (new)</li></ul>
<h3>DB schema — assignments</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>assignable_type</td><td>enum(shift, policy)</td><td>What is being assigned</td></tr>
<tr><td>assignable_id</td><td>unsignedBigInteger</td><td>shifts.id or policies.id</td></tr>
<tr><td>assignable_subtype</td><td>varchar(20) nullable</td><td>leave / holiday when assigning a policy; denormalised so overlap checks can tell leave from holiday</td></tr>
<tr><td>scope_type</td><td>enum(company, branch, division, department, team, employee)</td><td>Which level this applies to</td></tr>
<tr><td>scope_id</td><td>unsignedBigInteger nullable</td><td>PK of the matching table; NULL when scope_type = company. For employee this is <strong>employee_personal_infos.id</strong></td></tr>
<tr><td>effective_date</td><td>date</td><td>When the assignment starts</td></tr>
<tr><td>end_date</td><td>date nullable</td><td>When it ends, if applicable</td></tr>
<tr><td>status</td><td>varchar(20) default Active</td><td>Active / Inactive</td></tr>
<tr><td>created_by</td><td>unsignedBigInteger nullable</td><td>Who made the assignment</td></tr>
</table>
<p>Keys: index(company_id, scope_type, scope_id) · index(company_id, assignable_type, effective_date)</p>
<p>Scope tables: branch to branches, division to divisions, department to departments, team to teams, employee to <strong>employee_personal_infos</strong>. There is no org_units table in this codebase.</p>
<h3>API endpoints</h3>
<pre>GET    /api/v1/attendance/assignments
GET    /api/v1/attendance/assignments?scope_type=&amp;scope_id=&amp;history=true
POST   /api/v1/attendance/assignments
GET    /api/v1/attendance/assignments/{id}
PATCH  /api/v1/attendance/assignments/{id}
POST   /api/v1/attendance/assignments/{id}/end</pre>
<p>POST /assignments/bulk belongs to task 1.4b-BE.</p>
<h3>Business rules</h3>
<ul>
<li>A shift, leave policy or holiday policy is assigned to one of six scope levels; scope_id must exist in the table matching scope_type and belong to the same company.</li>
<li>effective_date is required. end_date is optional and must not precede it.</li>
<li><strong>Overlap rule:</strong> for a given (company_id, scope_type, scope_id, assignable_type, assignable_subtype), no two Active rows may have overlapping date ranges. An open-ended row overlaps everything after its start.</li>
<li>The overlap check runs inside a transaction with SELECT ... FOR UPDATE on the scope existing rows. A database constraint cannot express this. <strong>It is exposed as a single service method so task 1.4b-BE can reuse it per row rather than reimplementing it.</strong></li>
<li>Only Active shifts and policies are assignable.</li>
<li>History is never destroyed: ending an assignment sets end_date, it does not delete the row. PATCH may only correct end_date and status, never the scope or the assignable.</li>
<li>Every write emits AssignmentChanged so the resolver cache (task 2.1-BE) can invalidate.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>assignable_type, assignable_id, scope_type, effective_date — required.</li>
<li>assignable_subtype — required when assignable_type = policy, and must match the referenced policy policy_type.</li>
<li>scope_id — required unless scope_type = company; must exist and be same-company.</li>
<li>end_date must be on or after effective_date when present.</li>
<li>Referenced shift or policy must have status = Active.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Overlapping active assignment — 409, response includes the conflicting assignment id and date range</li>
<li>Assigning an Inactive shift or policy — 422</li>
<li>scope_id not found in the table implied by scope_type — 404</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can assign a shift to one employee or to an entire department in a single action.</li>
<li>A second active shift assignment overlapping an existing one for the same scope is blocked with the conflicting range shown.</li>
<li>A leave-policy assignment and a holiday-policy assignment to the same scope and dates both succeed — they do not conflict with each other.</li>
<li>A future-dated assignment is accepted and takes effect on its effective date without further action.</li>
<li>Complete assignment history remains queryable after a new assignment supersedes an old one.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for assignments.</li>
<li>Overlap detection unit-tested against open-ended ranges, adjacent ranges and identical ranges.</li>
<li>Scope resolution unit-tested for all six scope_type values.</li>
<li>The overlap check extracted as one reusable service method, consumed by task 1.4b-BE.</li>
<li>api collection/Attendance/Assignments/*.yml</li>
</ul>',
 10.00, 'todo', 'high', @s2, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.4b-BE Bulk Assignment — Backend',
 'att-1-4b-be-bulk-assignment',
 @vo + 12, @type_be,
 '<h3>Summary</h3><p>As HR, I want to assign a shift or policy to a whole department in one action and see per-employee results, so one conflicting employee does not force me to redo the batch.</p>
<p><strong>Split note.</strong> Split from the original 8-point 1.4-BE. Reuses task 1.4a-BE overlap check per row; adds nothing to the schema.</p>
<h3>Start after</h3><p>1.4a-BE Assignments</p>
<h3>Permission</h3><p><code>attendance.assignment-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 1.4-FE, Bulk Assign section</p>
<h3>Related tables</h3><ul><li><code>assignments</code> — see task 1.4a-BE. <strong>No new tables, no new columns.</strong></li></ul>
<h3>API endpoints</h3>
<pre>POST /api/v1/attendance/assignments/bulk
GET  /api/v1/attendance/assignments/bulk/{batchId}</pre>
<h3>Business rules</h3>
<ul>
<li>Bulk assignment takes an org-unit filter plus an optional employee multi-select, and creates one assignments row per resolved employee through task 1.4a-BE service — the overlap rule is not reimplemented here.</li>
<li><strong>Per-row isolation:</strong> each employee is attempted in its own transaction and reports its own success or failure. A single conflicting employee must not fail the whole batch.</li>
<li>Batches above attendance.bulk_assignment_queue_threshold (default 200) run as a <strong>queued job</strong> returning 202 Accepted with a batch id and a status endpoint, mirroring ProcessEmployeeImportJob.</li>
<li>Batches at or below the threshold run synchronously and return the result table directly.</li>
<li>The status endpoint reports progress (total, processed, succeeded, failed) and, once complete, the per-employee reason for each failure.</li>
<li>Batch results are retained long enough for HR to read them after navigating away — at least 24 hours.</li>
<li>Every successful row emits AssignmentChanged, exactly as the single-assignment path does.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function bulkAssign(filter, employee_ids, assignable, effective_date, end_date):
    employees = resolveEmployees(filter, employee_ids)   # org-unit filter + explicit picks
    result    = { total: count(employees), succeeded: [], failed: [] }

    for employee in employees.chunk(100):
        try:
            transaction:                                 # per-row, not per-batch
                AssignmentService.create(                # 1.4a-BE - overlap check inside
                    assignable, scope_type: "employee", scope_id: employee.id,
                    effective_date, end_date)
            result.succeeded.push(employee.id)
        catch e:
            result.failed.push({ employee_id: employee.id, reason: e.message })

    return result</pre>
<h3>Validation</h3>
<ul>
<li>One of filter or employee_ids is required; both together is allowed and unions the two sets.</li>
<li>assignable_type, assignable_id, effective_date — required, validated exactly as in task 1.4a-BE.</li>
<li>A resolved set of zero employees — 422, rather than a batch that silently does nothing.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Batch above the threshold — 202 Accepted with a batch id (not an error)</li>
<li>Empty resolved employee set — 422</li>
<li>A per-employee overlap conflict — recorded in failed with the conflicting assignment date range; <strong>the batch continues</strong></li>
<li>Unknown batchId — 404</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A bulk assign over 200 employees returns a batch id and completes in the background with a per-row result report.</li>
<li>A batch of 10 in which 2 employees have conflicting assignments creates 8 assignments and reports 2 failures with their reasons — it does not roll back the other 8.</li>
<li>A batch of 50 returns its result synchronously without a batch id.</li>
<li>The status endpoint reports progress while the job is still running.</li>
<li>Re-reading the batch status after 12 hours still returns the result table.</li>
<li>An empty filter result is rejected rather than reported as a successful batch of zero.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Queued job with a status endpoint, modelled on ProcessEmployeeImportJob.</li>
<li>Partial-failure test: assert succeeded rows persist when a sibling row fails.</li>
<li>Threshold boundary tested at exactly 200 and at 201.</li>
<li>api collection/Attendance/Assignments/bulk-*.yml</li>
</ul>',
 6.00, 'todo', 'high', @s2, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '1.4-FE Shift & Policy Assignment — Frontend',
 'att-1-4-fe-assignments',
 @vo + 13, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to assign and review shifts and policies across employees and org units, and to see the full assignment history for anyone.</p>
<h3>Start after</h3><p>1.4a-BE Assignments</p>
<h3>Also needs (can be stubbed)</h3><p>1.4b-BE Bulk Assignment</p>
<h3>Permission</h3><p><code>attendance.assignment-manage</code> (write), <code>attendance.menu-view</code> (read)</p>
<h3>Menu</h3><p><strong>Attendance › Configuration › Assignments</strong></p>
<h3>Related tables</h3><ul><li><code>assignments</code> — see task 1.4a-BE for the schema.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/config/assignments
/attendance/config/assignments/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Assignment List</strong> — filters for assignable type, scope level, org unit and employee; columns show what is assigned, to whom, and the effective range.</li>
<li><strong>Add Assignment form</strong> — assignable picker (shift / leave policy / holiday policy), scope-level select that swaps the second picker between company, branch, division, department, team and employee.</li>
<li><strong>Bulk Assign screen</strong> — org-unit filter, resulting employee list with multi-select, then a per-row result table after submission.</li>
<li><strong>End Assignment action</strong> — sets end_date via a date picker.</li>
<li><strong>Assignment History timeline</strong> — per employee or org unit, showing superseded rows greyed with their date ranges.</li>
</ul>
<h3>API integration</h3>
<pre>GET    /api/v1/attendance/assignments
GET    /api/v1/attendance/assignments?scope_type=&amp;scope_id=&amp;history=true
POST   /api/v1/attendance/assignments
POST   /api/v1/attendance/assignments/bulk
PATCH  /api/v1/attendance/assignments/{id}
POST   /api/v1/attendance/assignments/{id}/end</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Pick scope level</td><td>in the form</td><td>—</td><td>Second picker swaps between company / branch / division / department / team / employee</td><td>—</td></tr>
<tr><td>2</td><td><strong>Assign</strong></td><td><code>attendance.assignment-manage</code></td><td><code>POST /assignments</code></td><td>Row appears in list</td><td>409 overlap → <strong>inline conflict card</strong> with the existing range and a link to end it</td></tr>
<tr><td>3</td><td><strong>Bulk assign</strong></td><td><code>assignment-manage</code></td><td><code>POST /assignments/bulk</code></td><td>≤200: result table returned inline; &gt;200: progress view with a batch id</td><td>422 empty resolved set → stated before anything is created</td></tr>
<tr><td>4</td><td>Leave and return during a bulk run</td><td>batch running</td><td><code>GET /assignments/bulk/{batchId}</code></td><td>Progress view restored</td><td>—</td></tr>
<tr><td>5</td><td><strong>End assignment</strong></td><td><code>assignment-manage</code></td><td><code>POST /{id}/end</code></td><td><code>end_date</code> set; row moves to history</td><td>422 <code>end_date</code> before <code>effective_date</code></td></tr>
<tr><td>6</td><td><strong>Edit</strong></td><td><code>assignment-manage</code></td><td><code>PATCH /{id}</code></td><td>Only end date and status editable; scope and assignable are read-only</td><td>—</td></tr>
<tr><td>7</td><td>View <strong>history</strong></td><td>always</td><td><code>GET /assignments?scope_type=&amp;scope_id=&amp;history=true</code></td><td>Timeline with the currently effective row marked distinctly from past and future-dated</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>The scope picker is a two-step control — level first, then the entity — so an employee is never confused with a department in one flat list.</li>
<li>Assignable pickers list only Active shifts and policies; inactive ones are absent, not disabled.</li>
<li>A 409 overlap renders as an inline conflict card showing the existing assignment range with a link to end it, not as a toast.</li>
<li>Bulk submission above the threshold switches to a progress view polling the batch status; the user can leave and return.</li>
<li>The bulk result table separates succeeded and failed rows, with the failure reason per employee and a copy-to-clipboard action.</li>
<li>The history timeline marks the currently effective row distinctly from past and future-dated rows.</li>
<li>Scope and assignable fields are read-only in edit mode — only the end date and status are editable.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR assigns a shift to a whole department in one action and sees a per-employee result.</li>
<li>An overlapping assignment shows which existing assignment conflicts and offers to end it.</li>
<li>A future-dated assignment is visibly distinguished from the currently effective one.</li>
<li>Leaving the page during a bulk run and returning restores the progress view.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/assignmentApi.ts</code> with batch-status polling.</li>
<li>Nav entry under Attendance, Configuration section.</li>
</ul>',
 10.00, 'todo', 'high', @s2, NULL, NULL, @now, @now);

-- =====================================================================
--  PART B — ASSIGNMENT & CALCULATION FOUNDATION  (4 tasks, 17 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '2.1-BE Assignment Resolution Service — Backend',
 'att-2-1-be-assignment-resolution',
 @vo + 14, @type_be,
 '<h3>Summary</h3><p>As the system, I want one service that resolves the effective shift, leave policies, holiday calendar and timezone for any employee on any date, so calculation, leave validation and payroll never duplicate that logic.</p>
<h3>Start after</h3><p>1.4a-BE Assignments</p>
<h3>Permission</h3><p><code>attendance.assignment-preview</code> (diagnostic endpoint)</p>
<h3>Menu</h3><p>none — API only; surfaces in 2.1-FE</p>
<h3>Related tables</h3>
<ul>
<li><code>assignments</code> — existing from 1.4a-BE; no new columns.</li>
<li><code>employee_organization_assignments</code> — read-only, from the Employee module.</li>
</ul>
<h3>API endpoints</h3>
<pre>GET /api/v1/attendance/resolve/assignment?employee_id=&amp;date=</pre>
<h3>Business rules</h3>
<ul>
<li><strong>Most-specific-wins precedence</strong>, evaluated independently per assignable type: employee, then team, then department, then division, then branch, then company.</li>
<li>Org-unit membership is read <strong>as of the requested date</strong>, not as of today. An employee transferred mid-month must resolve against the unit they belonged to on that date, using employee_organization_assignments history.</li>
<li>Resolution respects effective_date and end_date ranges and is deterministic for a given (employee_id, date).</li>
<li>A date with no active shift is returned as an explicit unassigned result — <strong>never silently defaulted</strong>.</li>
<li>Results are cached per (company_id, employee_id, date) and invalidated on AssignmentChanged, HolidayPolicyUpdated and EmployeeTransferred.</li>
<li>EmployeeTransferred does not yet exist — <strong>this task adds it</strong> to EmployeeOrganizationAssignmentService::transfer() in the Employee module.</li>
<li>The Attendance Calculation Engine (3.2), leave validation (5.2) and payroll (8.2) call this service and never query assignments directly.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function resolve(employee_id, date):
    scopes = [
      ["employee",   employee_id],
      ["team",       teamIdOf(employee_id, date)],
      ["department", departmentIdOf(employee_id, date)],
      ["division",   divisionIdOf(employee_id, date)],
      ["branch",     branchIdOf(employee_id, date)],
      ["company",    null],
    ]

    for assignableType in [shift, policy:leave, policy:holiday]:
        for (type, id) in scopes:                 # most specific first
            row = activeAssignment(company, type, id, assignableType, date)
            if row:
                resolved[assignableType]     = row
                resolvedFrom[assignableType] = type      # returned for diagnostics
                break

    if resolved[shift] is null:
        return ResolvedContext.unassigned(reason: "no_shift")

    return ResolvedContext{
        shift, leave_policies,
        holiday_calendar: expandRecurring(holidayPolicy, year(date)),
        timezone: shift.timezone ?? company.timezone,
        resolved_from: resolvedFrom
    }</pre>
<h3>Validation</h3>
<ul><li>employee_id and date — required; employee must exist and belong to the caller company.</li></ul>
<h3>Error handling</h3>
<ul>
<li>No active shift for the date — 422 with code unassigned and the reason</li>
<li>Unknown employee_id — 404</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Given overlapping employee-level and department-level assignments, the employee-level one wins.</li>
<li>An employee transferred from Department A to Department B on the 15th resolves A shift for the 10th and B shift for the 20th.</li>
<li>A date in an unassigned gap returns 422 unassigned, not a default shift.</li>
<li>Cache is invalidated by all three events; a resolve immediately after an assignment change returns the new result.</li>
<li>Calculation engine, leave validation and payroll receive identical context for the same input, verified by an integration test.</li>
<li>Recurring holidays are expanded correctly for the requested year, including a leap-year date.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Precedence and date-range edge cases covered by unit tests, including adjacent and open-ended ranges.</li>
<li>Cache invalidation verified against all three triggering events.</li>
<li>EmployeeTransferred event added to the Employee module and dispatched on transfer.</li>
<li>api collection/Attendance/Resolution/*.yml</li>
</ul>',
 16.00, 'todo', 'high', @s3, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '2.1-FE Assignment Resolution Preview — Frontend',
 'att-2-1-fe-resolution-preview',
 @vo + 15, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to preview which shift, policies and holiday calendar apply to any employee on any date, so I can verify setup before problems reach payroll.</p>
<h3>Start after</h3><p>2.1-BE Assignment Resolver</p>
<h3>Permission</h3><p><code>attendance.assignment-preview</code></p>
<h3>Menu</h3><p><strong>Attendance › Configuration › Assignments</strong> — linked from that screen, deliberately <strong>not</strong> its own nav item</p>
<h3>Frontend routes</h3><pre>/attendance/config/assignment-preview</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Preview tool</strong> — employee picker plus date picker, resolving on change.</li>
<li><strong>Resolved result panel</strong> — shift, leave policies, holiday calendar, timezone; each row annotated with the scope level it resolved from, for example Shift from Department.</li>
<li><strong>Unassigned warning state</strong> — a distinct, prominent panel when the API returns 422 unassigned.</li>
</ul>
<h3>API integration</h3><pre>GET /api/v1/attendance/resolve/assignment?employee_id=&amp;date=</pre>
<h3>UI rules</h3>
<ul>
<li>The screen is reachable only by holders of attendance.assignment-preview; it is not in the primary nav but is linked from the Assignments screen.</li>
<li>The resolved-from annotation is always visible — the point of the tool is explaining why, not just what.</li>
<li>An unassigned result renders as a warning card with a link to create the missing assignment, never as an empty panel.</li>
<li>Changing either input re-resolves without a submit button.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can look up the applicable shift, policies and calendar for any employee on any date.</li>
<li>Each resolved value shows which scope level produced it.</li>
<li>An unassigned gap is clearly surfaced with a route to fix it.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/resolutionApi.ts</code></li>
<li>Linked from the Assignments screen.</li>
</ul>',
 4.00, 'todo', 'high', @s3, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '2.2-BE Policy Snapshotting — Backend',
 'att-2-2-be-policy-snapshot',
 @vo + 16, @type_be,
 '<h3>Summary</h3><p>As the system, I want every calculated attendance record to store the exact policy values used at calculation time, so a later policy edit never silently changes historical numbers.</p>
<h3>Start after</h3><p>2.1-BE Assignment Resolver</p>
<h3>Permission</h3><p><code>attendance.record-recalculate</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 2.2-FE</p>
<h3>Related tables</h3>
<ul><li><code>attendance_records</code> — column added here; the table itself is created in task 3.2-BE.</li></ul>
<h3>DB schema — attendance_records, new column</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>policy_snapshot</td><td>json</td><td>The resolved shift/policy values used for this record calculation</td></tr>
</table>
<h4>Snapshot contents</h4>
<pre>{
  "shift_id": 3,
  "grace_minutes": 15,
  "working_hours": 8.00,
  "min_hours_present": 6.00,
  "min_hours_half_day": 3.00,
  "working_days": [7,1,2,3,4],
  "timezone": "Asia/Dhaka",
  "resolved_at": "2026-07-27T02:00:00Z"
}</pre>
<h3>API endpoints</h3>
<pre>POST /api/v1/attendance/attendance-records/{id}/recalculate
     body: { "use_current_policy": false }</pre>
<h3>Business rules</h3>
<ul>
<li>The calculation engine embeds resolved policy <strong>values</strong>, not just ids, into policy_snapshot.</li>
<li>Recalculation uses the stored snapshot by default. Re-resolving live policy happens only when use_current_policy = true, which requires attendance.record-recalculate.</li>
<li>No calculated record is ever written without a snapshot.</li>
<li>Recalculation is blocked on records with is_locked = true regardless of permission.</li>
<li>A missing snapshot on an older record falls back to re-resolving current policy and writes a warning to the activity log rather than failing.</li>
</ul>
<h3>Validation</h3>
<ul><li>use_current_policy — required boolean.</li></ul>
<h3>Error handling</h3>
<ul>
<li>Recalculate on a locked record — 409</li>
<li>use_current_policy = true without the permission — 403</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Changing a shift grace period today does not change the late/on-time status of a record calculated last month.</li>
<li>The default recalculation path reads the stored snapshot, proven by a test that mutates the shift between calculation and recalculation.</li>
<li>use_current_policy = true re-applies the new rules and is audit-logged with before and after values.</li>
<li>No row exists in attendance_records with a null policy_snapshot after task 3.2-BE ships.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Default and override recalculation paths separately unit-tested.</li>
<li>Audit-log entry asserted for the override path.</li>
</ul>',
 10.00, 'todo', 'high', @s3, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '2.2-FE Applied Policy Panel — Frontend',
 'att-2-2-fe-applied-policy-panel',
 @vo + 17, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to see exactly which policy values applied to a specific historical day, so I can explain or audit any calculated status.</p>
<h3>Start after</h3><p>2.2-BE Policy Snapshotting · 3.2-FE Daily Summary</p>
<h3>Permission</h3><p><code>attendance.record-recalculate</code></p>
<h3>Menu</h3><p><strong>Attendance › Attendance Records</strong> — embedded panel on the record detail (3.2-FE), no own nav item</p>
<h3>Frontend routes</h3><p>Surfaces inside the Attendance Record detail view built in task 3.2-FE.</p>
<h3>Main screen sections</h3>
<ul>
<li><strong>Policy applied panel</strong> — read-only card on the record detail view showing grace period, thresholds, expected hours, working days and timezone as of that date, with the resolved_at timestamp.</li>
<li><strong>Recalculate action</strong> — a split control offering Recalculate (snapshot) and Recalculate with current policy (override), the second gated on permission and behind a confirmation dialog.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/attendance/attendance-records/{id}/recalculate</pre>
<h3>UI rules</h3>
<ul>
<li>The override option carries an explicit warning that it can change historical numbers, and requires a typed confirmation, not a single click.</li>
<li>Both actions are hidden on locked records, with a lock badge explaining why.</li>
<li>After recalculation the panel refreshes in place and highlights any value that changed.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can see the exact policy values behind any historical day status.</li>
<li>The override action cannot be triggered accidentally.</li>
<li>Locked records show no recalculate action at all.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Panel and split action added to the record detail view.</li>
</ul>',
 4.00, 'todo', 'high', @s3, NULL, NULL, @now, @now);

-- =====================================================================
--  PART C — RUNTIME  (4 tasks, 21 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '3.1-BE Multi-Punch Check-In / Check-Out — Backend',
 'att-3-1-be-multi-punch',
 @vo + 18, @type_be,
 '<h3>Summary</h3><p>As an employee, I want to record multiple check-in and check-out events in a day so breaks and multiple entries are tracked accurately.</p>
<h3>Start after</h3><p>2.1-BE Assignment Resolver</p>
<h3>Permission</h3><p><code>attendance.punch-create</code>, <code>attendance.punch-create-others</code> (on behalf of)</p>
<h3>Menu</h3><p>none — API only; surfaces in 3.1-FE</p>
<h3>Related tables</h3><ul><li><code>attendance_punches</code> (new)</li></ul>
<h3>DB schema — attendance_punches</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>attendance_date</td><td>date</td><td>Logical attendance day; handles overnight shifts</td></tr>
<tr><td>punch_type</td><td>enum(in, out)</td><td>Direction</td></tr>
<tr><td>punch_time</td><td>datetime</td><td>Server time, stored UTC</td></tr>
<tr><td>source</td><td>enum(web, mobile, biometric, api, manual)</td><td>Origin</td></tr>
<tr><td>ip_address</td><td>varchar(45) nullable</td><td>IP at time of punch</td></tr>
<tr><td>device_info</td><td>varchar(255) nullable</td><td>Device/browser info</td></tr>
<tr><td>remarks</td><td>varchar(255) nullable</td><td>Optional note</td></tr>
<tr><td>sequence_no</td><td>int</td><td>Order of punch within the day</td></tr>
<tr><td>superseded_by_id</td><td>bigint nullable</td><td>Set when a correction replaces this punch</td></tr>
<tr><td>correction_request_id</td><td>bigint nullable</td><td>Set on punches created by an approved correction</td></tr>
<tr><td>created_at</td><td>timestamp</td><td>Record creation time</td></tr>
</table>
<p>Keys: index(company_id, employee_id, attendance_date) · index(superseded_by_id)</p>
<p><strong>Append-only.</strong> No updated_at, no update endpoint, no delete endpoint.</p>
<h3>API endpoints</h3>
<pre>POST /api/v1/attendance/punch
GET  /api/v1/attendance/punches?date=&amp;employee_id=</pre>
<h3>Business rules</h3>
<ul>
<li>An employee may punch in and out multiple times within the same attendance_date.</li>
<li>Two consecutive punches of the same type are rejected — an in requires an intervening out and vice versa. The check considers only non-superseded punches.</li>
<li>punch_time is always server time. A client-supplied timestamp is ignored unless source = manual <strong>and</strong> the caller holds attendance.punch-create-others.</li>
<li>attendance_date is resolved through the effective shift and timezone (task 2.1-BE). For an overnight shift, a 01:00 punch belongs to the previous day attendance_date.</li>
<li>Punching on a date that resolves as unassigned is rejected with code unassigned.</li>
<li>Every punch records source, IP, device info and optional remarks.</li>
<li>sequence_no is lastNonSuperseded.sequence_no + 1, starting at 1.</li>
<li>Corrections never edit a punch. They insert new rows and stamp superseded_by_id on the ones they replace (task 5.5-BE). All calculation queries filter whereNull(superseded_by_id).</li>
<li>On an out punch, emit PunchCreated, which queues the day recalculation (task 3.2-BE).</li>
<li>Employees may punch only for themselves unless they hold attendance.punch-create-others.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function recordPunch(employee_id, punch_type, source, ip, remarks):
    context = AssignmentResolver.resolve(employee_id, today(company_timezone))
    if context.unassigned:
        raise Error("unassigned")

    date = resolveAttendanceDate(context, now())        # handles overnight shift
    last = lastPunch(employee_id, date) where superseded_by_id is null

    if last and last.punch_type == punch_type:
        raise Error("Cannot punch the same type twice in a row")

    punch = AttendancePunch.create({
        employee_id, attendance_date: date, punch_type,
        punch_time: now(), source, ip_address: ip, remarks,
        sequence_no: last ? last.sequence_no + 1 : 1
    })

    if punch_type == "out":
        emit PunchCreated(punch)        # queues calculateDaily
    return punch</pre>
<h3>Validation</h3>
<ul>
<li>punch_type — required, within the enum.</li>
<li>source — required, within the enum; only manual is accepted from a privileged caller with an explicit punch_time.</li>
<li>remarks — max 255.</li>
<li>employee_id — accepted only from a caller holding attendance.punch-create-others; otherwise derived from the authenticated user.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Two consecutive same-type punches — 422 naming the last punch time</li>
<li>Unassigned date — 422 with code unassigned</li>
<li>employee_id supplied without punch-create-others — 403</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee can check in, check out for lunch, check in again and check out for the day — four separate ordered punches.</li>
<li>A second consecutive check-in without an intervening check-out is rejected, and vice versa.</li>
<li>Each punch stores its own source, time and IP independently.</li>
<li>An overnight-shift employee 01:00 punch is attributed to the previous day attendance_date.</li>
<li>A superseded punch is excluded from the consecutive-type check and from all calculations.</li>
<li>No endpoint exists that can modify or delete a punch.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Overnight attendance_date resolution unit-tested across a DST-free and a DST-observing timezone.</li>
<li>Supersession filtering asserted in the consecutive-type check.</li>
<li>api collection/Attendance/Punches/*.yml</li>
</ul>',
 10.00, 'todo', 'high', @s4, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '3.1-FE Multi-Punch Check-In / Check-Out — Frontend',
 'att-3-1-fe-multi-punch',
 @vo + 19, @type_fe,
 '<h3>Summary</h3><p>As an employee, I want a single obvious control to check in and out, and a clear view of today punches.</p>
<h3>Start after</h3><p>3.1-BE Punches</p>
<h3>Permission</h3><p><code>attendance.punch-create</code></p>
<h3>Menu</h3><p><strong>Attendance › Punch</strong> — plus a dashboard widget</p>
<h3>Frontend routes</h3><pre>/attendance/punch</pre>
<p>Plus a compact punch widget on the dashboard.</p>
<h3>Main screen sections</h3>
<ul>
<li><strong>Punch button</strong> — one toggle reading Check In or Check Out based on the last non-superseded punch of the day.</li>
<li><strong>Today punch timeline</strong> — chronological list with source icons, times in the employee shift timezone, and a superseded marker where applicable.</li>
<li><strong>Remarks field</strong> — optional, shown at the moment of punching.</li>
<li><strong>Dashboard widget</strong> — same button plus today total hours so far.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/attendance/punch
GET  /api/v1/attendance/punches?date=today</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Check In / Check Out</strong></td><td><code>attendance.punch-create</code>; label derived from the last non-superseded punch <strong>on the server</strong>, not local state</td><td><code>POST /punch</code></td><td>Timeline gains a row; button flips</td><td>422 consecutive same type → re-reads state rather than assuming</td></tr>
<tr><td>2</td><td>Same, while a request is in flight</td><td>—</td><td>—</td><td>Button <strong>disabled</strong> until the response lands</td><td>This is what prevents a double-click creating a rejected punch</td></tr>
<tr><td>3</td><td>Add <strong>remarks</strong></td><td>at the moment of punching</td><td>included in <code>POST /punch</code></td><td>Stored with the punch</td><td>—</td></tr>
<tr><td>4</td><td>Open the screen with no shift assigned</td><td>—</td><td><code>POST</code> never fires</td><td>The button is <strong>replaced</strong> by an explanatory panel, not shown with an error</td><td>422 <code>unassigned</code> handled before render</td></tr>
<tr><td>5</td><td>Dashboard widget punch</td><td>anywhere in the app</td><td><code>POST /punch</code></td><td>Same as #1, plus today’s running total</td><td>—</td></tr>
</table>
<p><strong>Not offered:</strong> editing or deleting a punch. The table is append-only; corrections go through 5.4-FE.</p>
<h3>UI rules</h3>
<ul>
<li>The button label and colour derive from server state, not local state — after a failed request it re-reads rather than assuming.</li>
<li>The button is disabled while a punch is in flight, to prevent a double-submit creating a rejected consecutive punch.</li>
<li>An unassigned 422 renders as an explanatory panel (no shift is assigned to you for today, contact HR), replacing the button rather than showing a raw error.</li>
<li>Times display in the shift timezone with the zone abbreviation, so an overnight-shift employee is not confused by a date that differs from the wall clock.</li>
<li>Superseded punches remain visible, struck through, with a link to the correction that replaced them.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>The button always reflects the correct next action after a page refresh.</li>
<li>Double-clicking cannot produce two punches.</li>
<li>An employee with no shift assigned sees a clear explanation, not an error toast.</li>
<li>Today timeline matches the server exactly, including superseded entries.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/punchApi.ts</code></li>
<li>Dashboard widget registered; nav entry under Attendance.</li>
</ul>',
 6.00, 'todo', 'high', @s4, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '3.2-BE Daily Attendance Summary — Backend',
 'att-3-2-be-daily-summary',
 @vo + 20, @type_be,
 '<h3>Summary</h3><p>As an employee or HR, I want the system to compute a daily summary from punches so status, working hours and overtime are always accurate and explainable.</p>
<h3>Start after</h3><p>3.1-BE Punches · 2.2-BE Policy Snapshotting</p>
<h3>Permission</h3><p><code>attendance.record-view-own</code> / <code>-team</code> / <code>-all</code>, <code>attendance.record-export</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 3.2-FE</p>
<h3>Related tables</h3><ul><li><code>attendance_records</code> (new)</li></ul>
<h3>DB schema — attendance_records</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>attendance_date</td><td>date</td><td>Which day</td></tr>
<tr><td>shift_id</td><td>unsignedBigInteger nullable</td><td>Shift applicable that day</td></tr>
<tr><td>attendance_type_id</td><td>unsignedBigInteger</td><td>Calculated status</td></tr>
<tr><td>first_check_in</td><td>datetime nullable</td><td>Day first in-punch</td></tr>
<tr><td>last_check_out</td><td>datetime nullable</td><td>Day last out-punch</td></tr>
<tr><td>total_working_hours</td><td>decimal(6,2) default 0</td><td>Sum of paired in/out durations</td></tr>
<tr><td>overtime_hours</td><td>decimal(6,2) default 0</td><td>Auto-calculated</td></tr>
<tr><td>late_minutes</td><td>int default 0</td><td>Minutes past grace period</td></tr>
<tr><td>early_leave_minutes</td><td>int nullable</td><td>Minutes left early</td></tr>
<tr><td>punch_count</td><td>int default 0</td><td>Non-superseded punches that day</td></tr>
<tr><td>policy_snapshot</td><td>json</td><td>From task 2.2-BE; never null on a calculated row</td></tr>
<tr><td>is_locked</td><td>boolean default false</td><td>True once the month is approved</td></tr>
<tr><td>calculated_at</td><td>timestamp nullable</td><td>Last calculation time</td></tr>
</table>
<p>Keys: unique(company_id, employee_id, attendance_date) · index(company_id, attendance_date)</p>
<h3>API endpoints</h3>
<pre>GET /api/v1/attendance/attendance-records/today
GET /api/v1/attendance/attendance-records
GET /api/v1/attendance/attendance-records/{id}
GET /api/v1/attendance/attendance-records/{id}/punches
GET /api/v1/attendance/attendance-records/export?format=xlsx|csv</pre>
<h3>Business rules</h3>
<ul>
<li>The summary pairs non-superseded punches for each attendance_date to produce first check-in, last check-out, total working hours and punch count.</li>
<li>Status is resolved by system_code on attendance_types — never by name or id.</li>
<li>Recalculation runs automatically after every check-out (PunchCreated) and after any approved correction or leave.</li>
<li>A locked record is never recalculated by any automatic path.</li>
<li><strong>Punch voids leave (decision R2):</strong> when an active leave_request_days row exists for the date and punches also exist, the day is calculated from the punches and the leave day is voided with voided_reason = punched. LeaveService::voidLeaveDay() refunds the day to the balance with a reversal ledger row, in the same transaction as the record write.</li>
<li>Voiding is per <strong>date</strong>. Other days of the same multi-day leave request are unaffected, and leave_requests itself is never mutated.</li>
<li>A locked day never voids a leave, because it is never recalculated.</li>
<li><strong>Visibility:</strong> record-view-own gives self only; record-view-team gives employees within the caller reporting scope via employee_reporting_managers; record-view-all gives the whole company.</li>
<li>Exports above attendance.export_queue_threshold (default 5000 rows) run as a queued job returning a download token.</li>
<li>Emits AttendanceCalculated on every write, and LeaveDayVoided when a leave day is voided.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function calculateDaily(employee_id, date):
    punches = punches(employee_id, date) where superseded_by_id is null
              order by punch_time asc
    context = AssignmentResolver.resolve(employee_id, date)

    if context.unassigned:                    return          # no record; flagged for HR
    if isHoliday(date, context):              return upsert(type: holiday, hours: 0)
    if not isWorkingDay(date, context.shift): return upsert(type: weekend, hours: 0)

    leave_day = activeLeaveDay(employee_id, date)   # leave_request_days, status active

    if punches is empty:
        return leave_day ? upsert(type: leave,  hours: 0)
                         : upsert(type: absent, hours: 0)

    # punches exist -&gt; the day is calculated from attendance; leave voided below

    shift          = context.shift
    first_check_in = punches[0].punch_time
    last           = punches[len(punches) - 1]
    last_check_out = last.punch_type == "out" ? last.punch_time : null

    total_minutes = 0; session_in = null
    for p in punches:
        if p.punch_type == "in":                    session_in = p.punch_time
        elif p.punch_type == "out" and session_in:  total_minutes += diff(session_in, p.punch_time)
                                                    session_in = null
    # a trailing unmatched in is not counted

    working_hours       = total_minutes / 60
    late_minutes        = max(0, diff(shift.start_time, first_check_in) - shift.grace_minutes)
    early_leave_minutes = last_check_out ? max(0, diff(last_check_out, shift.end_time)) : null
    overtime_hours      = max(0, working_hours - shift.working_hours)

    status =
        last_check_out is null                                          ? missing_check_out :
        working_hours &gt;= shift.min_hours_present and late_minutes == 0  ? present :
        working_hours &gt;= shift.min_hours_present                        ? late :
        working_hours &gt;= shift.min_hours_half_day                       ? half_day :
                                                                          absent

    # --- punch voids leave ---
    if leave_day:
        if leave_day.day_value == 1.00 or working_hours &gt;= shift.min_hours_present:
            LeaveService.voidLeaveDay(leave_day, reason: "punched")   # refunds balance
            emit LeaveDayVoided(leave_day)
        else:
            status = half_day   # half-day leave stands; worked half is the other half

    record = upsert AttendanceRecord{ ..., policy_snapshot: snapshotOf(context) }
    emit AttendanceCalculated(record)
    return record</pre>
<h3>Three rules that make this correct</h3>
<ol>
<li>Holiday and weekend checks run <strong>before</strong> no-punches-means-absent. Otherwise every weekend is recorded as Absent and the monthly totals are wrong.</li>
<li>missing_check_out is evaluated <strong>first</strong>, not last. Otherwise an employee who worked eight hours and forgot to check out is recorded as Present with a null checkout.</li>
<li><strong>A punch beats an approved leave</strong> (decision R2). Returning LEAVE before ever looking at the punches means an employee who came in anyway loses the day balance and is recorded as not having worked.</li>
</ol>
<p><strong>Half-day carve-out.</strong> A half-day leave is voided only when the punches show a full day of work. Voiding on any punch would make half-day leave unusable, since the employee always punches for the half they work.</p>
<p><strong>Split of delivery.</strong> leave_request_days and LeaveService::voidLeaveDay() do not exist yet at Part C — leave lands in Part E. This task implements everything except the two leave_day branches, behind a LeaveDayResolver interface with a null implementation. <strong>Task 5.3b-BE supplies the real implementation and the tests for those branches.</strong></p>
<h3>Validation</h3>
<ul><li>Query filters (employee_id, org unit, date range, status) validated; date range capped at 366 days per request.</li></ul>
<h3>Error handling</h3>
<ul>
<li>Requesting another employee record without the required scope permission — 403</li>
<li>Export request over the threshold — 202 with a download token</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>After an employee final check-out, the summary shows correct total working hours and status.</li>
<li>A weekend with no punches is recorded as Weekend, not Absent.</li>
<li>An eight-hour day with no check-out is recorded as Missing Check-Out, not Present.</li>
<li>An approved correction triggers recalculation of the affected day.</li>
<li>Locked summaries are not altered by any automatic recalculation.</li>
<li>LeaveDayResolver is injected and its null implementation returns no leave day, so the calculation behaves exactly as specified with leave absent. The voiding branches are exercised in task 5.3b-BE.</li>
<li>An employee with only record-view-own cannot read a colleague record.</li>
<li>A manager with record-view-team sees exactly their reporting scope, no more.</li>
<li>Every written record has a non-null policy_snapshot.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Every branch of the status ladder unit-tested, including the ordering rules above.</li>
<li>Session-pairing tested with an unmatched trailing in and with three in/out pairs.</li>
<li>Visibility scoping tested for all three permission levels.</li>
<li>Queued export with a download token.</li>
<li>api collection/Attendance/Attendance Records/*.yml</li>
</ul>',
 16.00, 'todo', 'high', @s4, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '3.2-FE Daily Attendance Summary — Frontend',
 'att-3-2-fe-daily-summary',
 @vo + 21, @type_fe,
 '<h3>Summary</h3><p>As an employee I want to see my own day at a glance, and as HR I want a filterable grid of everyone daily attendance with an export.</p>
<h3>Start after</h3><p>3.2-BE Daily Summary</p>
<h3>Permission</h3><p><code>attendance.record-view-own</code> / <code>-team</code> / <code>-all</code>, <code>attendance.record-export</code></p>
<h3>Menu</h3><p><strong>Attendance › Attendance Records</strong> — plus a Today card on the dashboard</p>
<h3>Related tables</h3><ul><li><code>attendance_records</code> — see task 3.2-BE for the schema.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/attendance-records
/attendance/attendance-records/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Today Attendance card</strong> — self-service: status chip, hours so far, punch count; visible to every employee.</li>
<li><strong>Daily Summary grid</strong> — HR view with filters for employee, org unit, date range and status; columns for status, first in, last out, hours, overtime, late minutes.</li>
<li><strong>Export button</strong> — xlsx/csv; switches to a progress state and a download link when the job is queued.</li>
<li><strong>Record detail view</strong> — the day numbers, the underlying punch timeline (from 3.1-FE) and the applied-policy panel (from 2.2-FE).</li>
</ul>
<h3>API integration</h3>
<pre>GET /api/v1/attendance/attendance-records/today
GET /api/v1/attendance/attendance-records
GET /api/v1/attendance/attendance-records/{id}
GET /api/v1/attendance/attendance-records/{id}/punches
GET /api/v1/attendance/attendance-records/export?format=xlsx|csv</pre>
<h3>UI rules</h3>
<ul>
<li>Status chips use each attendance type configured colour and icon from task 1.1, so configuration is visibly connected to output.</li>
<li>Late minutes and early-leave minutes render only when non-zero, to keep the grid scannable.</li>
<li>A missing_check_out row is visually distinct from absent — they mean different things to HR.</li>
<li>A day whose leave was voided by a punch carries a small marker linking to the leave request, so the employee can see why their balance changed.</li>
<li>The grid employee and org-unit filters are hidden entirely for a user with only record-view-own.</li>
<li>A locked record shows a lock badge in both the grid and the detail view.</li>
<li>Export over the threshold shows a progress indicator and a persistent download link, surviving navigation.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee sees their own day without any filter controls.</li>
<li>HR can filter by department and date range and export the result.</li>
<li>A missing-check-out day is immediately distinguishable from an absent day.</li>
<li>The detail view shows punches and applied policy on one screen.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/attendanceRecordApi.ts</code> with export polling.</li>
<li>Today card registered on the dashboard; nav entry under Attendance.</li>
</ul>',
 10.00, 'todo', 'high', @s4, NULL, NULL, @now, @now);

-- =====================================================================
--  PART D — APPROVAL ENGINE INTEGRATION  (2 tasks, 8 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '4.1-BE Approval Engine Integration — Backend',
 'att-4-1-be-approval-integration',
 @vo + 22, @type_be,
 '<h3>Summary</h3><p>As the system, I want Correction, Leave, Monthly Attendance and Payroll approvals routed through the existing platform approval engine, because a mature versioned workflow engine already exists and a second one must not be built.</p>
<h3>Start after</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p>Platform admin (existing approval-settings permissions)</p>
<h3>Menu</h3><p>none — API only; surfaces in 4.1-FE</p>
<h3>Related tables</h3>
<p>All existing. <strong>No new tables, no new columns.</strong> modules, module_actions, approval_workflows, approval_workflow_versions, approval_steps, approver_resolvers, approval_settings, approval_requests, approval_request_steps, approval_request_approvers, approval_payloads, approval_audits.</p>
<h3>DB schema — approval_requests (existing, the real shape)</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id, uuid</td><td>bigint PK, uuid</td><td>Identity</td></tr>
<tr><td>company_id</td><td>FK companies</td><td>Multi-tenant scope</td></tr>
<tr><td>module_id, module_action_id</td><td>FK</td><td>Which action is being approved</td></tr>
<tr><td>workflow_version_id</td><td>FK</td><td>Pinned workflow version</td></tr>
<tr><td>requester_id</td><td>FK users</td><td>Submitter</td></tr>
<tr><td>status</td><td>enum</td><td>pending, approved, rejected, cancelled, failed, executed</td></tr>
<tr><td>title</td><td>string</td><td>Human-readable summary</td></tr>
<tr><td>correlation_id</td><td>varchar(100) nullable</td><td><strong>unique(company_id, correlation_id)</strong> — link back to the business record</td></tr>
<tr><td>submitted_at, completed_at, executed_at</td><td>timestamp</td><td>Lifecycle</td></tr>
</table>
<p>Step tracking lives in approval_request_steps and approval_request_approvers; history in approval_audits; the pending change body in approval_payloads. <strong>Do not add approvable_type, approval_flow_id, current_step, or a history JSON column anywhere.</strong></p>
<h3>API endpoints</h3>
<p>No new endpoints. Setup uses existing platform routes:</p>
<pre>POST /api/v1/module-actions        (one-time: register the 4 approvable actions)
POST /api/v1/approval-settings     (per company: wire each action to a workflow)</pre>
<p>Approve and reject at runtime use the platform existing approval-requests endpoints. <strong>No Attendance or Payroll module adds its own approve/reject route.</strong></p>
<h3>Module actions to register</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Module</th><th>Action slug</th><th>Permission key</th><th>Entity type (executor key)</th></tr>
<tr><td>attendance</td><td>correction-approve</td><td>attendance.correction-approve</td><td>attendance_correction</td></tr>
<tr><td>attendance</td><td>leave-approve</td><td>attendance.leave-approve</td><td>leave_request</td></tr>
<tr><td>attendance</td><td>monthly-approve</td><td>attendance.monthly-approve</td><td>monthly_attendance</td></tr>
<tr><td>payroll</td><td>run-approve</td><td>payroll.run-approve</td><td>payroll_run</td></tr>
<tr><td>payroll</td><td>advance-approve</td><td>payroll.advance-approve</td><td>salary_advance</td></tr>
</table>
<h3>Business rules</h3>
<ul>
<li>Five distinct module_actions, so each can carry an independent workflow.</li>
<li>Submission always goes through the platform ApprovalGateway::submit() (app/Platform/Services) with an ApprovalSubmissionData DTO. Modules never write to approval_requests directly.</li>
<li><strong>correlation_id is prefixed</strong>, because the column is unique per company and raw ids collide across types: leave_request:{id}, correction_request:{id}, monthly_attendance:{id}, payroll_run:{id}, salary_advance:{id}.</li>
<li>When approval_settings.approval_enabled = 0 for an action, the gateway executes the onApproved closure synchronously and writes no approval row. <strong>This is the documented bypass, not an error.</strong></li>
<li>Each entity type gets one ApprovalExecutorInterface implementation registered in ApprovalExecutorRegistry from the module service provider. The executor is the <strong>only</strong> place a business record transitions to its approved state, so the bypass path and the workflow path share identical logic.</li>
<li>Business records keep their own status column for querying and display. Step and approver history is read from the platform approval endpoints via correlation_id — never duplicated onto business tables.</li>
</ul>
<h3>Reference implementation</h3>
<pre>$result = $this-&gt;approvalGateway-&gt;submit(new ApprovalSubmissionData(
    companyId:     $companyId,
    requesterId:   $userId,
    moduleSlug:    "attendance",
    actionSlug:    "leave-approve",
    operation:     ApprovalOperation::Custom,
    entityType:    "leave_request",
    entityId:      (string) $leaveRequest-&gt;id,
    payloadBefore: null,
    payloadAfter:  $leaveRequest-&gt;toApprovalPayload(),
    title:         "Leave request",
    correlationId: "leave_request:" . $leaveRequest-&gt;id,
    onApproved:    fn () =&gt; $this-&gt;leaveApprovalService-&gt;apply($leaveRequest),
));</pre>
<p>Registration, in the module service provider:</p>
<pre>$this-&gt;app-&gt;make(ApprovalExecutorRegistryContract::class)
    -&gt;register("leave_request", LeaveRequestExecutor::class);</pre>
<p>Model the executor on the Employee module EmployeeBankAccountExecutor (Modules/Employee/app/Approval).</p>
<h3>Executor responsibilities</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Entity type</th><th>On approval the executor does</th></tr>
<tr><td>attendance_correction</td><td>insert superseding punches, recalculate the day, mark the request approved</td></tr>
<tr><td>leave_request</td><td>re-validate balance, deduct with a ledger row, create day rows, stamp leave on working days, mark approved</td></tr>
<tr><td>monthly_attendance</td><td>set month approved, lock the month records, set ready_for_payroll</td></tr>
<tr><td>payroll_run</td><td>set run approved, finalize its payslips, settle netted salary advances (task 8.3a-BE)</td></tr>
<tr><td>salary_advance</td><td>set the advance approved and stamp approved_by — it does <strong>not</strong> pay the money; HR records the handover separately</td></tr>
</table>
<p>This task creates the five executor <strong>stubs</strong> with their interface and registration. Each executor body is implemented by its own task (5.5-BE, 5.3a-BE, 6.1-BE, 8.3a-BE, 7.5-BE).</p>
<p>salary_advance has a second bypass in front of the platform one: when payroll_settings.advance_requires_approval = false, task 7.5-BE approves the advance without calling the gateway at all. The executor is still the only place the record transitions, so both paths call it.</p>
<h3>Validation</h3>
<ul>
<li>An action must have an approval_settings row before any request against it is submitted; if none exists, treat as approval_enabled = 0.</li>
<li>An enabled action with no configured workflow causes the gateway to raise a validation error.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Enabled action with no workflow — 422</li>
<li>Unknown moduleSlug/actionSlug pair — 422 from the gateway</li>
<li>Duplicate correlation_id within a company — 409, meaning a second request for the same record is already pending</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>All five module actions are registered and independently configurable with their own workflow.</li>
<li>A submitted request appears in approval_requests with the prefixed correlation_id and the correct module_action_id.</li>
<li>With approval_enabled = 0, the action executes immediately and no approval_requests row is written.</li>
<li>With approval_enabled = 1, the record stays pending until the workflow completes, then the executor runs exactly once.</li>
<li>A leave request with id 5 and a correction request with id 5 in the same company can both be pending simultaneously.</li>
<li>migrate:status shows no new approval-related migration from this task.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Five executors registered, each with a passing stub test asserting registry resolution.</li>
<li>approval_settings configured for all five actions in the demo company via a seeder, following ConfigurationApprovalSeeder.</li>
<li>Integration test covering both the bypass and the workflow path for one action.</li>
</ul>',
 10.00, 'todo', 'high', @s2, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '4.1-FE Approval Settings Exposure — Frontend',
 'att-4-1-fe-approval-settings',
 @vo + 23, @type_fe,
 '<h3>Summary</h3><p>As an administrator, I want to configure workflows for the five new approvable actions inside the existing Approval Settings screen, without a separate configuration surface.</p>
<h3>Start after</h3><p>4.1-BE Approval Integration</p>
<h3>Permission</h3><p>Existing platform admin permissions</p>
<h3>Menu</h3><p><strong>Admin › Approval Settings</strong> — the existing platform screen, extended not rebuilt</p>
<h3>Related tables</h3><ul><li>approval_settings, approval_workflows — existing.</li></ul>
<h3>Frontend routes</h3><pre>/admin/approval-settings        (existing screen, extended not rebuilt)</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Existing Approval Settings screen</strong> — the five new module actions appear in the existing module-action picker once seeded.</li>
<li><strong>Existing Workflow Builder</strong> — used as-is to design each action step sequence.</li>
<li><strong>Pending-approval indicators</strong> — small additions to the Attendance and Payroll list screens showing a Pending approval badge derived from the business record own status.</li>
</ul>
<h3>API integration</h3>
<pre>GET  /api/v1/module-actions
POST /api/v1/approval-settings</pre>
<h3>UI rules</h3>
<ul>
<li>No new configuration screen is built. If the five actions do not appear in the existing picker, the fix belongs in the seeder, not the UI.</li>
<li>Where a module screen shows an approval state, it reads the business record status field; it does not query the approval tables directly.</li>
<li>A record awaiting approval renders its action buttons disabled with a pending-approval tooltip rather than hiding them, so users understand why they cannot act.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An administrator can find and configure a workflow for all five new actions in the existing screen.</li>
<li>Attendance and Payroll list screens show a pending-approval badge on records awaiting a decision.</li>
<li>No new admin screen was added.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Shared ApprovalStatusBadge component in modules/core/components for reuse by tasks 5.x, 6.x and 8.x.</li>
</ul>',
 6.00, 'todo', 'high', @s2, NULL, NULL, @now, @now);

-- =====================================================================
--  PART E — BUSINESS LAYER  (11 tasks, 43 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '5.1-BE Leave Balance Management — Backend',
 'att-5-1-be-leave-balances',
 @vo + 24, @type_be,
 '<h3>Summary</h3><p>As HR, I want accurate per-employee, per-year leave balances with a full audit trail, so entitlement, usage, carry-forward and encashment are always explainable.</p>
<h3>Start after</h3><p>1.3-BE Policies</p>
<h3>Also needs (can be stubbed)</h3><p>1.4a-BE Assignments</p>
<h3>Permission</h3><p><code>attendance.leave-balance-view</code>, <code>attendance.leave-balance-adjust</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 5.1-FE</p>
<h3>Related tables</h3>
<ul><li><code>leave_balances</code> (new)</li><li><code>leave_balance_ledger</code> (new)</li></ul>
<h3>DB schema — leave_balances</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>leave_policy_id</td><td>unsignedBigInteger</td><td>policies.id where policy_type = leave</td></tr>
<tr><td>year</td><td>smallint</td><td>Balance year</td></tr>
<tr><td>entitled_days</td><td>decimal(6,2) default 0</td><td>Total entitlement accrued so far</td></tr>
<tr><td>used_days</td><td>decimal(6,2) default 0</td><td>Consumed via approved leave</td></tr>
<tr><td>carried_forward_days</td><td>decimal(6,2) default 0</td><td>Carried from the previous year</td></tr>
<tr><td>encashed_days</td><td>decimal(6,2) default 0</td><td>Encashed amount</td></tr>
</table>
<p>Keys: unique(company_id, employee_id, leave_policy_id, year)</p>
<p><strong>Available balance is derived, never stored:</strong> available = entitled_days + carried_forward_days - used_days - encashed_days</p>
<h3>DB schema — leave_balance_ledger</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>leave_balance_id</td><td>unsignedBigInteger</td><td>Parent balance row</td></tr>
<tr><td>entry_type</td><td>enum(accrual, carry_forward, consumption, reversal, encashment, manual_adjustment)</td><td>What happened</td></tr>
<tr><td>days</td><td>decimal(6,2)</td><td>Signed — negative for consumption</td></tr>
<tr><td>reference_type, reference_id</td><td>varchar(50) nullable, unsignedBigInteger nullable</td><td>e.g. leave_request and its id</td></tr>
<tr><td>reason</td><td>varchar(255) nullable</td><td>Mandatory for manual_adjustment</td></tr>
<tr><td>created_by</td><td>unsignedBigInteger nullable</td><td>Actor</td></tr>
<tr><td>created_at</td><td>timestamp</td><td>When</td></tr>
</table>
<p>Keys: index(leave_balance_id, created_at)</p>
<h3>API endpoints</h3>
<pre>GET   /api/v1/attendance/leave-balances?employee_id=&amp;year=&amp;policy_id=
GET   /api/v1/attendance/leave-balances/{employeeId}/history?year=
GET   /api/v1/attendance/leave-balances/me?year=current
PATCH /api/v1/attendance/leave-balances/{id}/adjust</pre>
<h3>Business rules</h3>
<ul>
<li>A balance row is created per employee, per assigned leave policy, per year. Creation is triggered by a listener on leave-policy assignment (task 1.4a-BE) and by the accrual job (task 9.2-BE).</li>
<li><strong>Every mutation to leave_balances writes a leave_balance_ledger row in the same transaction.</strong> The sum of ledger rows must equal the balance columns — this is assertable and is tested.</li>
<li>used_days increases when a leave request is approved (task 5.3a-BE) and decreases via a reversal entry when an approved leave is cancelled or a day is voided by a punch.</li>
<li>Carry-forward at year end follows the policy rule and cap (task 9.2-BE).</li>
<li>encashed_days is tracked separately and reduces available balance.</li>
<li>Manual adjustment requires attendance.leave-balance-adjust and a mandatory reason, and writes a manual_adjustment ledger row.</li>
<li>An employee may always read their own balances; reading another employee balances requires attendance.leave-balance-view.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function adjust(balance_id, days, reason, actor):
    balance = LeaveBalance.lockForUpdate(balance_id)

    transaction:
        balance.entitled_days += days           # signed
        assert balance.available &gt;= 0 or allow_negative
        balance.save()
        Ledger.create({ balance_id, entry_type: "manual_adjustment",
                        days, reason, created_by: actor })</pre>
<h3>Validation</h3>
<ul>
<li>days — required, non-zero decimal.</li>
<li>reason — required, max 255, for manual adjustment.</li>
<li>Adjustment that would drive available below zero is rejected unless an explicit allow_negative flag is passed.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Adjustment without a reason — 422</li>
<li>Adjustment driving available below zero without allow_negative — 422 stating the resulting figure</li>
<li>Reading another employee balance without permission — 403</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A new employee balance row is created automatically once a leave policy is assigned to them.</li>
<li>Approving a leave request reduces the correct balance row with a consumption ledger entry, without manual intervention.</li>
<li>Cancelling an approved leave writes a reversal entry and restores the balance.</li>
<li>Manual adjustment without a reason is rejected.</li>
<li>For any employee, policy and year, the sum of ledger days equals entitled_days + carried_forward_days - used_days - encashed_days, asserted by a test.</li>
<li>An employee can read their own balance without leave-balance-view.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Ledger-to-balance reconciliation test.</li>
<li>Row-level locking used on every balance mutation, tested under a concurrent-approval scenario.</li>
<li>api collection/Attendance/Leave Balances/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.1-FE Leave Balance Management — Frontend',
 'att-5-1-fe-leave-balances',
 @vo + 25, @type_fe,
 '<h3>Summary</h3><p>As HR, I want a balance grid with a readable ledger behind each figure, so I can answer why a number is what it is without a database query.</p>
<h3>Start after</h3><p>5.1-BE Leave Balances</p>
<h3>Permission</h3><p><code>attendance.leave-balance-view</code>, <code>attendance.leave-balance-adjust</code></p>
<h3>Menu</h3><p><strong>Attendance › Leave › Balances</strong> — plus a My Balances dashboard card</p>
<h3>Related tables</h3><ul><li>leave_balances, leave_balance_ledger — see task 5.1-BE.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/leave/balances
/attendance/leave/balances/{employeeId}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Balance grid</strong> — employee by policy for the selected year, showing entitled, carried forward, used, encashed and the derived available.</li>
<li><strong>Ledger view</strong> — chronological entries behind one balance row, with entry type, signed days, reference link, actor and reason.</li>
<li><strong>Manual adjustment form</strong> — signed day count and a mandatory reason, with a live preview of the resulting available balance.</li>
<li><strong>My balances card</strong> — self-service summary for the logged-in employee.</li>
</ul>
<h3>API integration</h3>
<pre>GET   /api/v1/attendance/leave-balances?employee_id=&amp;year=
GET   /api/v1/attendance/leave-balances/{employeeId}/history?year=
GET   /api/v1/attendance/leave-balances/me?year=current
PATCH /api/v1/attendance/leave-balances/{id}/adjust</pre>
<h3>UI rules</h3>
<ul>
<li>Available balance is computed and labelled as derived, so nobody mistakes it for an editable field.</li>
<li>The adjustment form shows before and after figures before submission; a negative result is blocked unless the user explicitly ticks allow negative.</li>
<li>Ledger entries link to their source: a consumption row links to the leave request, a carry_forward row to the previous year balance, a reversal row to the voided day.</li>
<li>Reference and reason columns are never truncated without a hover expansion — they are the audit trail.</li>
<li>Users without leave-balance-adjust see the ledger but no adjustment control.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can open any balance and read every entry that produced it.</li>
<li>An adjustment shows its effect before it is applied.</li>
<li>An employee sees only their own balances when they lack leave-balance-view.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/leaveBalanceApi.ts</code></li>
<li>My-balances card registered on the dashboard; nav entry under Attendance, Leave section.</li>
</ul>',
 6.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.2-BE Leave Application — Backend',
 'att-5-2-be-leave-application',
 @vo + 26, @type_be,
 '<h3>Summary</h3><p>As an employee, I want to submit a leave application against a policy assigned to me, with the working-day count and my balance shown before I submit.</p>
<h3>Start after</h3><p>5.1-BE Leave Balances · 2.1-BE Assignment Resolver · 4.1-BE Approval Integration</p>
<h3>Permission</h3><p><code>attendance.leave-apply</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 5.2-FE</p>
<h3>Related tables</h3><ul><li><code>leave_requests</code> (new)</li></ul>
<h3>DB schema — leave_requests</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>leave_policy_id</td><td>unsignedBigInteger</td><td>policies.id where policy_type = leave</td></tr>
<tr><td>start_date, end_date</td><td>date</td><td>Leave period</td></tr>
<tr><td>duration_type</td><td>enum(full_day, half_day_first, half_day_second)</td><td>Full or half day</td></tr>
<tr><td>total_days</td><td>decimal(5,2)</td><td>Auto-calculated working days</td></tr>
<tr><td>reason</td><td>text</td><td>Mandatory justification</td></tr>
<tr><td>attachment_path</td><td>varchar(255) nullable</td><td>Supporting document</td></tr>
<tr><td>status</td><td>enum(pending, approved, rejected, cancelled)</td><td>Lifecycle</td></tr>
<tr><td>decided_by, decided_at</td><td>unsignedBigInteger nullable, timestamp nullable</td><td>Decision metadata</td></tr>
<tr><td>decision_reason</td><td>text nullable</td><td>Mandatory on reject</td></tr>
</table>
<p>Keys: index(company_id, employee_id, start_date) · index(company_id, status)</p>
<h3>API endpoints</h3>
<pre>POST  /api/v1/attendance/leave-requests
GET   /api/v1/attendance/leave-requests?employee_id=me&amp;status=
GET   /api/v1/attendance/leave-requests/{id}
PATCH /api/v1/attendance/leave-requests/{id}/cancel
POST  /api/v1/attendance/leave-requests/preview</pre>
<p>preview returns the computed total_days and current balance for a candidate date range without creating anything — it backs the live figure in the form.</p>
<h3>Business rules</h3>
<ul>
<li>An employee selects a leave policy <strong>from those assigned to them</strong> (resolved via task 2.1-BE), a date range and a duration type.</li>
<li>total_days counts <strong>working days only</strong> — dates in the resolved holiday calendar and non-working days of the resolved shift are excluded. Half-day types count 0.5 per counted day.</li>
<li>Submission is blocked when: the policy is not assigned to the employee; available balance is insufficient and the policy has lwp_allowed = false; the range overlaps an existing pending or approved request; the start date breaches advance_notice_days or backdate_limit_days; a document is required by document_required_after_days and none is attached.</li>
<li>On submit, the request is routed through ApprovalGateway::submit() with actionSlug leave-approve and correlationId leave_request:{id} (task 4.1-BE).</li>
<li>The employee may view status and cancel while the request is pending; cancelling also cancels the approval request.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function applyLeave(employee_id, policy_id, start_date, end_date, duration_type, reason):
    context = AssignmentResolver.resolve(employee_id, start_date)
    assertPolicyAssigned(context, policy_id)

    total_days = 0
    for date in dateRange(start_date, end_date):
        if date in context.holiday_calendar:        continue
        if not isWorkingDay(date, context.shift):   continue
        total_days += (duration_type == "full_day") ? 1 : 0.5

    if total_days == 0:
        raise Error("The selected range contains no working days")

    policy  = Policy.find(policy_id)
    balance = getLeaveBalance(employee_id, policy_id, year(start_date))

    if not policy.config.lwp_allowed and balance.available &lt; total_days:
        raise Error("Insufficient leave balance")
    if hasOverlappingLeave(employee_id, start_date, end_date):
        raise Error("Overlaps an existing pending or approved leave request")

    request = LeaveRequest.create({ ..., total_days, status: PENDING })

    ApprovalGateway.submit(ApprovalSubmissionData(
        moduleSlug: "attendance", actionSlug: "leave-approve",
        entityType: "leave_request", entityId: request.id,
        correlationId: "leave_request:" + request.id,
        onApproved: fn () =&gt; LeaveApprovalService.apply(request)))

    return request</pre>
<h3>Validation</h3>
<ul>
<li>leave_policy_id, start_date, end_date, duration_type, reason — required.</li>
<li>end_date must be on or after start_date.</li>
<li>duration_type other than full_day requires start_date equal to end_date and the policy half_day_allowed = true.</li>
<li>attachment_path — pdf/jpg/png, max 5 MB, matching the Employee module document rules.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Policy not assigned to the employee — 422</li>
<li>Insufficient balance — 422 stating available and requested days</li>
<li>Overlapping request — 409 naming the conflicting request dates</li>
<li>Range containing no working days — 422</li>
<li>Outside notice or backdate limits — 422 naming the limit</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee can submit a leave request only against a policy currently assigned to them.</li>
<li>A Thursday-to-Sunday request in a Sun to Thu working week counts only the working days, not four.</li>
<li>Preview shows the same total_days the submission produces.</li>
<li>An over-limit request is blocked when the policy disallows LWP, and permitted when it allows it.</li>
<li>Overlapping requests are blocked with the conflicting dates named.</li>
<li>The employee can cancel their own pending request, and the linked approval request is cancelled with it.</li>
<li>A half-day request spanning two dates is rejected.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Working-day counting unit-tested against holidays, weekends and a half-day.</li>
<li>Preview and submit share one calculation path, asserted by a test.</li>
<li>api collection/Attendance/Leave Requests/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.2-FE Leave Application — Frontend',
 'att-5-2-fe-leave-application',
 @vo + 27, @type_fe,
 '<h3>Summary</h3><p>As an employee, I want the leave form to show me exactly how many days will be deducted and what I have left, before I submit.</p>
<h3>Start after</h3><p>5.2-BE Leave Application</p>
<h3>Permission</h3><p><code>attendance.leave-apply</code></p>
<h3>Menu</h3><p><strong>Attendance › Leave › Requests</strong></p>
<h3>Related tables</h3><ul><li>leave_requests — see task 5.2-BE.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/leave/requests
/attendance/leave/requests/{id}
/attendance/leave/requests/new</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Apply Leave form</strong> — policy dropdown (assigned policies only), date range picker, duration type, live N-working-days figure, live balance before and after, reason, attachment upload.</li>
<li><strong>My Leave Requests list</strong> — status badges, date range, day count, policy.</li>
<li><strong>Detail view</strong> — submitted values, approval progress via the shared badge from 4.1-FE, and a Cancel button while pending.</li>
</ul>
<h3>API integration</h3>
<pre>POST  /api/v1/attendance/leave-requests
POST  /api/v1/attendance/leave-requests/preview
GET   /api/v1/attendance/leave-requests?employee_id=me
GET   /api/v1/attendance/leave-requests/{id}
PATCH /api/v1/attendance/leave-requests/{id}/cancel</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Pick policy</td><td><code>attendance.leave-apply</code></td><td>—</td><td>Only policies <strong>assigned to this employee</strong> are listed</td><td>—</td></tr>
<tr><td>2</td><td>Pick date range</td><td>policy selected</td><td><code>POST /leave-requests/preview</code> (debounced)</td><td>Live working-day count and before/after balance; non-working days greyed in the picker</td><td>—</td></tr>
<tr><td>3</td><td>Range spanning &gt;1 date</td><td>—</td><td>—</td><td>Duration type collapses to full-day</td><td>—</td></tr>
<tr><td>4</td><td>Range exceeding balance</td><td>—</td><td>—</td><td>LWP-allowed policy → <strong>warning, still submittable</strong>; otherwise blocked</td><td>—</td></tr>
<tr><td>5</td><td><strong>Attach document</strong></td><td>range &gt; <code>document_required_after_days</code></td><td>multipart with the submit</td><td>Required, with the reason stated</td><td>422 wrong type or &gt;5 MB</td></tr>
<tr><td>6</td><td><strong>Submit</strong></td><td>preview returned a non-zero day count</td><td><code>POST /leave-requests</code></td><td>Status <code>pending</code>; approval trail visible</td><td>409 overlap → conflicting dates named; 422 outside notice/backdate limits</td></tr>
<tr><td>7</td><td><strong>Cancel</strong></td><td>status = <code>pending</code></td><td><code>PATCH /{id}/cancel</code></td><td>Request cancelled <strong>and</strong> the linked approval request withdrawn — stated in the confirmation</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>The working-day count comes from preview, never from client-side date maths — the client does not know the employee holiday calendar.</li>
<li>Non-working days and holidays are visibly greyed in the date picker once a policy is selected.</li>
<li>The balance panel shows available and after-this-request, and turns into a warning (not a hard block) when the policy allows LWP and the request exceeds the balance.</li>
<li>Duration type collapses to full-day when the range spans more than one date.</li>
<li>The attachment field becomes required, with an explanation, once the selected range exceeds document_required_after_days.</li>
<li>Cancel is offered only while pending, and warns that it also withdraws the approval request.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>The day count in the form matches what the server records.</li>
<li>A range containing only weekends is rejected before submission with a clear message.</li>
<li>An LWP-eligible over-limit request warns but submits; a non-LWP one is blocked.</li>
<li>The attachment requirement appears automatically for long requests.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/leaveRequestApi.ts</code> with debounced preview.</li>
<li>Nav entry under Attendance, Leave section.</li>
</ul>',
 10.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now);

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '5.3a-BE Leave Approval — Backend',
 'att-5-3a-be-leave-approval',
 @vo + 28, @type_be,
 '<h3>Summary</h3><p>As HR, I want to review and decide leave applications so balances and attendance records stay synchronised in one atomic action.</p>
<p><strong>Split note.</strong> Split from the original 8-point 5.3-BE. This task owns the approval path and the per-day rows; <strong>5.3b-BE</strong> wires those rows into the punch-voids-leave rule (decision R2). Leave approval is fully usable before 5.3b lands.</p>
<h3>Start after</h3><p>5.2-BE Leave Application · 4.1-BE Approval Integration</p>
<h3>Permission</h3><p><code>attendance.leave-approve</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 5.3-FE</p>
<h3>Related tables</h3>
<ul>
<li><code>leave_request_days</code> (new)</li>
<li>leave_requests, leave_balances, leave_balance_ledger, attendance_records, approval_requests (existing by now)</li>
</ul>
<h3>DB schema — leave_request_days</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>leave_request_id</td><td>unsignedBigInteger</td><td>Parent request</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>leave_date</td><td>date</td><td>One row per counted working day</td></tr>
<tr><td>day_value</td><td>decimal(3,2)</td><td>1.00 or 0.50</td></tr>
<tr><td>status</td><td>enum(active, voided) default active</td><td>Whether this day is still consumed</td></tr>
<tr><td>voided_reason</td><td>varchar(100) nullable</td><td>Written by task 5.3b-BE; punched when voided by attendance</td></tr>
<tr><td>voided_at</td><td>timestamp nullable</td><td>When voided</td></tr>
</table>
<p>Keys: unique(company_id, employee_id, leave_date, leave_request_id) · index(leave_request_id, status)</p>
<p>A leave request spans a range, but voiding operates on a <strong>single date</strong>. Without per-day rows there is nowhere to record that day 3 of a five-day leave was cancelled, and no way to refund exactly one day. The columns exist from this task even though only 5.3b-BE and the cancel path write them.</p>
<h3>API endpoints</h3>
<pre>GET /api/v1/attendance/leave-requests?status=pending&amp;employee_id=&amp;policy_id=&amp;from=&amp;to=
GET /api/v1/attendance/leave-requests/{id}</pre>
<p>Approve and reject use the <strong>platform existing</strong> endpoints:</p>
<pre>POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject
GET  /api/v1/approval-requests/{id}/history</pre>
<p><strong>This module adds no approve or reject route.</strong></p>
<h3>Business rules</h3>
<ul>
<li>HR can view, search and filter leave requests by employee, policy, status and date range, within their visibility scope.</li>
<li>The decision is made through the approval engine. LeaveRequestExecutor (stub from task 4.1-BE) is implemented here and is the only place a leave request becomes approved.</li>
<li>The executor <strong>re-validates the balance at execution time</strong> — it may have changed between submission and approval — and fails the request with a clear reason rather than driving the balance negative.</li>
<li>On approval the executor, in one transaction: deducts used_days, writes a consumption ledger row referencing the request, creates one leave_request_days row per counted working day, and stamps the leave attendance type on <strong>working days only</strong>, using the same holiday and shift filter as task 5.2-BE. The sum of day_value must equal leave_requests.total_days.</li>
<li>On rejection a reason is mandatory and no balance, day row or attendance row changes.</li>
<li>Cancelling an already-approved leave voids its remaining active day rows, writes a reversal ledger row, clears the stamped attendance records and triggers recalculation of those days. <strong>This is the first consumer of voidLeaveDay(), which task 5.3b-BE generalises.</strong></li>
<li>Emits LeaveDecided.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>class LeaveRequestExecutor:
    function execute(payload):
        request = LeaveRequest.lockForUpdate(payload.entity_id)
        if request.status != PENDING:
            raise Error("Request already finalized")

        context = AssignmentResolver.resolve(request.employee_id, request.start_date)
        balance = LeaveBalance.lockForUpdate(request.employee_id, request.leave_policy_id,
                                             year(request.start_date))
        policy  = Policy.find(request.leave_policy_id)

        if not policy.config.lwp_allowed and balance.available &lt; request.total_days:
            raise Error("Balance became insufficient since submission")

        transaction:
            balance.used_days += request.total_days
            balance.save()
            Ledger.create({ balance, entry_type: "consumption", days: -request.total_days,
                            reference_type: "leave_request", reference_id: request.id })

            for date in dateRange(request.start_date, request.end_date):
                if date in context.holiday_calendar:       continue   # same filter as 5.2
                if not isWorkingDay(date, context.shift):  continue

                LeaveRequestDay.create({ request, leave_date: date,
                                         day_value: valueFor(request.duration_type),
                                         status: "active" })

                AttendanceRecord.upsert(request.employee_id, date,
                                        { attendance_type_id: typeBySystemCode("leave") })

            assert sum(request.days.day_value) == request.total_days

            request.status = APPROVED
            request.save()

        emit LeaveDecided(request)</pre>
<p>The holiday and weekend filter here is the same one applyLeave uses. Stamping every calendar date would inflate total_leave_days in the monthly summary and corrupt payroll pro-rating.</p>
<h3>Validation</h3>
<ul>
<li>Rejection requires decision_reason, max 500.</li>
<li>Approval is blocked when the dates now overlap another approved leave created since submission.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Balance became insufficient since submission — 422 stating both figures</li>
<li>Request already finalised — 409</li>
<li>Rejection without a reason — 422</li>
<li>Approving a leave whose dates fall in a locked month — 409</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Approving a leave request updates the balance and the affected days attendance in the same transaction; a failure in either rolls back both.</li>
<li>Approved leave is stamped only on working days — a Thursday-to-Sunday approval in a Sun to Thu week touches the working days only.</li>
<li>HR cannot reject without entering a reason.</li>
<li>Approval is blocked when the balance has since become insufficient, with the current figure shown.</li>
<li>Cancelling an approved leave restores the balance, voids its remaining day rows and recalculates the affected days.</li>
<li>The leave queue respects the caller visibility scope.</li>
<li>The sum of day_value over a request day rows equals its total_days on approval.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for leave_request_days.</li>
<li>LeaveRequestExecutor implemented, registered and tested through both the bypass and workflow paths.</li>
<li>LeaveService::voidLeaveDay(day, reason) implemented for the cancel path — task 5.3b-BE reuses it unchanged.</li>
<li>Transaction rollback tested by forcing a failure during attendance stamping.</li>
<li>Concurrency test: two approvers acting on requests that together exceed the balance — exactly one succeeds.</li>
</ul>',
 10.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.3b-BE Punch Voids Leave — Backend',
 'att-5-3b-be-punch-voids-leave',
 @vo + 29, @type_be,
 '<h3>Summary</h3><p>As HR, I want a day the employee actually worked to be calculated from their punches and returned to their leave balance, so nobody loses a leave day they did not use.</p>
<p><strong>Split note.</strong> Split from the original 8-point 5.3-BE. This task closes the loop opened in task 3.2-BE, which shipped LeaveDayResolver as a null implementation. Nothing here changes the schema.</p>
<h3>Start after</h3><p>5.3a-BE Leave Approval · 3.2-BE Daily Summary</p>
<h3>Permission</h3><p><code>attendance.leave-approve</code> (no new permission)</p>
<h3>Menu</h3><p>none — no endpoints at all; surfaces as the voided-day marker in 3.2-FE and the per-day status in 5.3-FE</p>
<h3>Related tables</h3><ul><li>leave_request_days, leave_balances, leave_balance_ledger, attendance_records — all existing. <strong>No new tables, no new columns.</strong></li></ul>
<h3>API endpoints</h3>
<p>None. This task changes calculation behaviour only; it is observable through the existing attendance-record and leave-balance endpoints.</p>
<h3>Business rules</h3>
<ul>
<li><strong>Decision R2:</strong> when an employee has an approved leave for a date and punches on that date, the punches win. The day is calculated from attendance and the leave day is voided and refunded.</li>
<li>Replaces the null LeaveDayResolver from task 3.2-BE with the real implementation, reading active rows from leave_request_days for the employee and date.</li>
<li>Enables the two leave_day branches in calculateDaily that task 3.2-BE left dormant, including the half-day carve-out.</li>
<li>Reuses LeaveService::voidLeaveDay(day, reason) from task 5.3a-BE unchanged — it sets status = voided, voided_reason = punched, voided_at; refunds day_value by decrementing used_days; and writes a reversal ledger row referencing the voided day, all in the caller transaction.</li>
<li>Voiding is per <strong>date</strong>, not per request. Other days of the same multi-day leave are unaffected, and leave_requests is never mutated. The effective consumed figure is the sum of day_value over active rows; the request keeps its original shape for audit.</li>
<li>A locked day never voids a leave, because a locked record is never recalculated.</li>
<li>Emits LeaveDayVoided.</li>
</ul>
<h3>Calculation pseudocode</h3>
<p>The two branches enabled in calculateDaily (full contract in task 3.2-BE):</p>
<pre>leave_day = LeaveDayResolver.activeLeaveDay(employee_id, date)   # was null before this task

if punches is empty:
    return leave_day ? upsert(type: leave,  hours: 0)
                     : upsert(type: absent, hours: 0)

# ... punches exist -&gt; status computed from attendance, as in 3.2-BE ...

if leave_day:
    if leave_day.day_value == 1.00 or working_hours &gt;= shift.min_hours_present:
        LeaveService.voidLeaveDay(leave_day, reason: "punched")   # refunds balance
        emit LeaveDayVoided(leave_day)
    else:
        status = half_day    # half-day leave stands; the worked half is the other half</pre>
<p><strong>Half-day carve-out.</strong> A half-day leave is voided only when the punches show a full day of work. Voiding on any punch would make half-day leave unusable, since the employee always punches for the half they work. This is the one condition to change if HR rules otherwise.</p>
<h3>Validation</h3>
<p>None — no new input surface.</p>
<h3>Error handling</h3>
<ul><li>A voiding failure rolls back the whole calculateDaily transaction, so a record is never written with an unrefunded leave day.</li></ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee with approved full-day leave who punches in gets a Present or Late record, the leave day is voided with voided_reason = punched, and exactly one day is refunded.</li>
<li>Voiding day 3 of a five-day leave leaves days 1, 2, 4 and 5 active, and leave_requests.total_days unchanged at 5.</li>
<li>A half-day leave plus a partial day of punches yields Half Day with the leave day intact.</li>
<li>A half-day leave plus a full day of punches voids the leave day and refunds 0.50.</li>
<li>A voided day writes a reversal ledger row, and the task 5.1-BE reconciliation test still passes afterwards.</li>
<li>A locked day never voids a leave, because it is never recalculated.</li>
<li>Re-running calculateDaily on an already-voided day refunds nothing further.</li>
<li>A day with an approved leave and <strong>no</strong> punches is still recorded as Leave, exactly as before this task.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Real LeaveDayResolver registered in place of the null implementation from task 3.2-BE.</li>
<li>Both calculateDaily leave branches covered by tests, including the half-day carve-out in both directions.</li>
<li>voidLeaveDay() idempotency test: calling it twice on the same day refunds once.</li>
<li>A regression test asserting the no-punch leave day is unchanged.</li>
</ul>',
 6.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.3-FE Leave Approval — Frontend',
 'att-5-3-fe-leave-approval',
 @vo + 30, @type_fe,
 '<h3>Summary</h3><p>As HR, I want a leave queue where I can see the balance impact of a decision before I make it.</p>
<h3>Start after</h3><p>5.3a-BE Leave Approval</p>
<h3>Also needs (can be stubbed)</h3><p>5.3b-BE Punch Voids Leave · 4.1-FE Approval Settings</p>
<h3>Permission</h3><p><code>attendance.leave-approve</code></p>
<h3>Menu</h3><p><strong>Attendance › Leave › Approvals</strong></p>
<h3>Related tables</h3><ul><li>leave_requests, leave_request_days — see tasks 5.2-BE and 5.3-BE.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/leave/approvals
/attendance/leave/approvals/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Leave queue</strong> — pending requests with filters for employee, policy, status and date range; row shows employee, policy, range and day count.</li>
<li><strong>Detail view</strong> — request details, attachment preview, a balance before/after panel, the working-days breakdown showing which dates will be stamped, and the approval step history.</li>
<li><strong>Approve / Reject actions</strong> — reject opens a modal requiring a reason.</li>
</ul>
<h3>API integration</h3>
<pre>GET  /api/v1/attendance/leave-requests?status=pending
GET  /api/v1/attendance/leave-requests/{id}
POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject
GET  /api/v1/approval-requests/{id}/history</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Open a request</td><td><code>attendance.leave-approve</code></td><td><code>GET /leave-requests/{id}</code></td><td>Detail with a <strong>freshly fetched</strong> balance panel — never carried from the list</td><td>—</td></tr>
<tr><td>2</td><td><strong>Approve</strong></td><td>balance panel finished loading</td><td><code>POST /approval-requests/{id}/approve</code></td><td>Balance deducted, days stamped, request <code>approved</code></td><td>422 balance became insufficient → <strong>inline</strong> with the current figure and a Refresh action</td></tr>
<tr><td>3</td><td><strong>Reject</strong></td><td>always</td><td><code>POST /approval-requests/{id}/reject</code></td><td>Request rejected; nothing else changes</td><td>422 empty reason — the field has no default text</td></tr>
<tr><td>4</td><td>Review the day breakdown</td><td>detail open</td><td>—</td><td>The exact dates that will be stamped, with holidays visibly skipped</td><td>—</td></tr>
<tr><td>5</td><td>View an approved request</td><td>—</td><td>—</td><td>Per-day status: <code>active</code>, or <strong>voided-by-punch with its void date</strong></td><td>—</td></tr>
</table>
<p><strong>Deliberately absent:</strong> an Approve button while the balance panel is still loading. Approving against a stale figure is the failure this screen exists to prevent.</p>
<h3>UI rules</h3>
<ul>
<li>The balance panel is fetched fresh when the detail view opens, not carried from the list — it may have changed since submission.</li>
<li>The working-days breakdown lists the exact dates that will be marked as leave, so an approver can see holidays being skipped.</li>
<li>On an approved request, the day list shows each day status — active or voided-by-punch, with the void date — so a request whose effective consumption differs from total_days explains itself.</li>
<li>A 422 balance-became-insufficient renders inline on the detail view with the current figure and a refresh action, not as a toast.</li>
<li>Approve is disabled while the balance panel is loading, so nobody approves against a stale figure.</li>
<li>Reject reason field has no default text and cannot be submitted empty.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An approver sees the balance impact and the exact dates affected before deciding.</li>
<li>Rejecting without a reason is impossible.</li>
<li>A stale-balance failure explains itself in place and offers a refresh.</li>
<li>A request with a punch-voided day shows which day was voided and when.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Reuses ApprovalStatusBadge from task 4.1-FE.</li>
<li>Nav entry under Attendance, Leave section.</li>
</ul>',
 6.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.4-BE Attendance Correction Request — Backend',
 'att-5-4-be-correction-request',
 @vo + 31, @type_be,
 '<h3>Summary</h3><p>As an employee, I want to request a correction for a specific attendance date so missing or incorrect punches can be fixed through a reviewed process.</p>
<h3>Start after</h3><p>3.2-BE Daily Summary · 4.1-BE Approval Integration</p>
<h3>Permission</h3><p><code>attendance.correction-create</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 5.4-FE</p>
<h3>Related tables</h3><ul><li><code>correction_requests</code> (new)</li></ul>
<h3>DB schema — correction_requests</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>attendance_date</td><td>date</td><td>Date being corrected</td></tr>
<tr><td>request_type</td><td>enum(missing_in, missing_out, incorrect_time, wrong_status, other)</td><td>Nature of the correction</td></tr>
<tr><td>requested_check_in</td><td>datetime nullable</td><td>Proposed check-in time</td></tr>
<tr><td>requested_check_out</td><td>datetime nullable</td><td>Proposed check-out time</td></tr>
<tr><td>reason</td><td>text</td><td>Mandatory justification</td></tr>
<tr><td>attachment_path</td><td>varchar(255) nullable</td><td>Supporting document</td></tr>
<tr><td>status</td><td>enum(pending, approved, rejected, cancelled)</td><td>Lifecycle</td></tr>
<tr><td>decided_by, decided_at</td><td>unsignedBigInteger nullable, timestamp nullable</td><td>Decision metadata</td></tr>
<tr><td>decision_reason</td><td>text nullable</td><td>Mandatory on reject</td></tr>
</table>
<p>Keys: index(company_id, employee_id, attendance_date) · index(company_id, status)</p>
<h3>API endpoints</h3>
<pre>POST  /api/v1/attendance/correction-requests
GET   /api/v1/attendance/correction-requests?employee_id=me&amp;status=
GET   /api/v1/attendance/correction-requests/{id}
PATCH /api/v1/attendance/correction-requests/{id}/cancel</pre>
<h3>Business rules</h3>
<ul>
<li>An employee submits a correction for their <strong>own</strong> attendance_date, choosing a request type, giving a reason and optionally attaching a document.</li>
<li>The date must fall inside the correction window — attendance.correction_window_days from the module config, default 30 — counted back from today.</li>
<li>A second pending request for the same attendance_date is blocked.</li>
<li>A request against a date in a locked month is accepted but flagged; approving it later requires attendance.correction-override-lock (task 5.5-BE).</li>
<li>On submit, routed through ApprovalGateway::submit() with actionSlug correction-approve and correlationId correction_request:{id}.</li>
<li>The employee may view status and cancel while pending.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>attendance_date, request_type, reason — required.</li>
<li>requested_check_in required for missing_in and incorrect_time; requested_check_out required for missing_out and incorrect_time.</li>
<li>Requested times must fall within the resolved shift span for that date, allowing for an overnight shift.</li>
<li>attendance_date not in the future.</li>
<li>Attachment rules match the Employee module document constraints.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Outside the correction window — 422 naming the window and the oldest permitted date</li>
<li>Second pending request for the same date — 409 referencing the existing request</li>
<li>Requested time outside the shift span — 422</li>
<li>Submitting for another employee — 403</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee can submit a correction with all mandatory fields validated per request type.</li>
<li>A second pending request for the same date is blocked.</li>
<li>A request older than the window is rejected at submission with the permitted range stated.</li>
<li>A request whose proposed time falls outside the shift is rejected.</li>
<li>The employee can track and cancel their own pending requests.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Per-request-type conditional validation unit-tested.</li>
<li>Correction window boundary tested at exactly the limit and one day past it.</li>
<li>api collection/Attendance/Correction Requests/*.yml</li>
</ul>',
 6.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.4-FE Attendance Correction Request — Frontend',
 'att-5-4-fe-correction-request',
 @vo + 32, @type_fe,
 '<h3>Summary</h3><p>As an employee, I want to raise a correction directly from the day that is wrong, with the current values shown beside what I am proposing.</p>
<h3>Start after</h3><p>5.4-BE Correction Request</p>
<h3>Also needs (can be stubbed)</h3><p>3.2-FE Daily Summary</p>
<h3>Permission</h3><p><code>attendance.correction-create</code></p>
<h3>Menu</h3><p><strong>Attendance › Corrections › Requests</strong> — also reachable pre-filled from the record detail (3.2-FE)</p>
<h3>Related tables</h3><ul><li>correction_requests — see task 5.4-BE.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/corrections
/attendance/corrections/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Add Correction form</strong> — date picker limited to the correction window, request type dropdown, conditional time fields, reason, attachment upload; shows the day current punches and status alongside the proposed values.</li>
<li><strong>My Correction Requests list</strong> — status badges, date, type.</li>
<li><strong>Detail view</strong> — original versus requested, side by side, with Cancel while pending.</li>
<li><strong>Entry point from the record detail</strong> — a Request correction action on the attendance record detail view (task 3.2-FE) that pre-fills the date.</li>
</ul>
<h3>API integration</h3>
<pre>POST  /api/v1/attendance/correction-requests
GET   /api/v1/attendance/correction-requests?employee_id=me
GET   /api/v1/attendance/correction-requests/{id}
PATCH /api/v1/attendance/correction-requests/{id}/cancel</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Request correction</strong> from a record</td><td>on the record detail (3.2-FE)</td><td>—</td><td>Opens this form with the date <strong>pre-filled</strong></td><td>—</td></tr>
<tr><td>2</td><td>Pick a date</td><td>picker min derived from the API’s correction window, never hardcoded</td><td>—</td><td>Dates with an existing pending request are disabled with a tooltip</td><td>—</td></tr>
<tr><td>3</td><td>Pick <code>request_type</code></td><td>—</td><td>—</td><td>Only the time fields that type requires are rendered</td><td>—</td></tr>
<tr><td>4</td><td><strong>Submit</strong></td><td><code>attendance.correction-create</code></td><td><code>POST /correction-requests</code></td><td>Status <code>pending</code></td><td>422 outside window → permitted range stated; 409 second pending request for the date</td></tr>
<tr><td>5</td><td><strong>Cancel</strong></td><td>status = <code>pending</code></td><td><code>PATCH /{id}/cancel</code></td><td>Cancelled</td><td>—</td></tr>
<tr><td>6</td><td>View detail</td><td>always</td><td><code>GET /{id}</code></td><td>Original values beside requested values</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>The date picker minimum is derived from the correction window returned by the API, not hardcoded in the client.</li>
<li>Time fields appear and disappear with the request type, and only the ones the type requires are rendered.</li>
<li>The current-values panel is always visible while composing, so the employee sees what they are changing.</li>
<li>A date that already has a pending request is disabled in the picker with an explanatory tooltip.</li>
<li>The form is reachable pre-filled from the attendance record detail, so nobody has to retype the date.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee raises a correction from the offending day in two clicks.</li>
<li>Dates outside the window and dates with a pending request cannot be selected.</li>
<li>The form shows current and proposed values together.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/correctionRequestApi.ts</code></li>
<li>Action wired from the attendance record detail view; nav entry under Attendance.</li>
</ul>',
 6.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.5-BE Attendance Correction Approval — Backend',
 'att-5-5-be-correction-approval',
 @vo + 33, @type_be,
 '<h3>Summary</h3><p>As HR, I want to decide correction requests so attendance records stay accurate while the original punch history remains intact and auditable.</p>
<h3>Start after</h3><p>5.4-BE Correction Request · 3.2-BE Daily Summary · 4.1-BE Approval Integration</p>
<h3>Also needs (can be stubbed)</h3><p>3.1-BE Punches</p>
<h3>Permission</h3><p><code>attendance.correction-approve</code>, <code>attendance.correction-override-lock</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 5.5-FE</p>
<h3>Related tables</h3><ul><li>correction_requests, attendance_punches, attendance_records, approval_requests</li></ul>
<h3>API endpoints</h3>
<pre>GET /api/v1/attendance/correction-requests?status=pending&amp;employee_id=&amp;from=&amp;to=
GET /api/v1/attendance/correction-requests/{id}</pre>
<p>Decisions use the platform existing approve/reject endpoints. <strong>No approve or reject route is added here.</strong></p>
<h3>Business rules</h3>
<ul>
<li>HR can view, search and filter correction requests by employee, org unit, status and date range, within their visibility scope.</li>
<li>AttendanceCorrectionExecutor (stub from task 4.1-BE) is implemented here and is the only place a correction is applied.</li>
<li><strong>Punches are never mutated or deleted.</strong> The executor inserts new punch rows with source = manual and correction_request_id set, then stamps superseded_by_id on the rows they replace. The audit trail must always show what the punches were before.</li>
<li>After applying punches the executor recalculates the day via task 3.2-BE.</li>
<li>Approving a correction whose month is locked requires attendance.correction-override-lock; the action is audit-logged prominently.</li>
<li>On rejection a reason is mandatory and no punch or record changes.</li>
<li>A finalised request cannot be decided again.</li>
<li>Emits CorrectionDecided.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>class AttendanceCorrectionExecutor:
    function execute(payload):
        request = CorrectionRequest.lockForUpdate(payload.entity_id)
        if request.status != PENDING:
            raise Error("Request already finalized")

        record = AttendanceRecord.find(request.employee_id, request.attendance_date)
        if record and record.is_locked
           and not actorHas("attendance.correction-override-lock"):
            raise Error("Month is locked")

        transaction:
            existing = punches(request.employee_id, request.attendance_date)
                       where superseded_by_id is null

            new_punches = buildPunchesFor(request)      # per request_type

            for p in new_punches:
                inserted = AttendancePunch.create({ ...p, source: "manual",
                                                    correction_request_id: request.id })
                if p.replaces:
                    p.replaces.superseded_by_id = inserted.id
                    p.replaces.save()

            calculateDaily(request.employee_id, request.attendance_date)   # task 3.2-BE

            request.status = APPROVED
            request.save()

        emit CorrectionDecided(request)</pre>
<h3>What buildPunchesFor produces</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>request_type</th><th>Produces</th></tr>
<tr><td>missing_in</td><td>one new in punch at requested_check_in, replacing nothing</td></tr>
<tr><td>missing_out</td><td>one new out punch at requested_check_out, replacing nothing</td></tr>
<tr><td>incorrect_time</td><td>new in and/or out punches replacing the day first in and last out</td></tr>
<tr><td>wrong_status</td><td>no punches; recalculation runs with an HR-set attendance_type_id override recorded in the activity log</td></tr>
<tr><td>other</td><td>no automatic punches; HR edits are captured in the request decision reason</td></tr>
</table>
<h3>Validation</h3>
<ul>
<li>Rejection requires decision_reason, max 500.</li>
<li>The resulting punch sequence must still satisfy the no-consecutive-same-type rule from task 3.1-BE; a correction that would break it is rejected.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Approving against a locked month without the override permission — 403</li>
<li>Request already finalised — 409</li>
<li>Resulting punch sequence invalid — 422 describing the conflict</li>
<li>Rejection without a reason — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Approving a pending request immediately updates the day summary.</li>
<li>The original punches remain in the table with superseded_by_id set — nothing is deleted or edited.</li>
<li>The superseded punches are excluded from recalculation and from the consecutive-type check.</li>
<li>HR cannot reject without a reason.</li>
<li>A finalised request cannot be approved or rejected again.</li>
<li>Approving a correction for a locked period requires the override permission and is audit-logged.</li>
<li>The corrected day policy_snapshot is preserved, not re-resolved (task 2.2-BE).</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>AttendanceCorrectionExecutor implemented, registered and tested for all five request types.</li>
<li>A test asserts that no punch row is ever updated except its superseded_by_id, and none is deleted.</li>
<li>Locked-month override path tested for both the permitted and forbidden caller.</li>
</ul>',
 10.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '5.5-FE Attendance Correction Approval — Frontend',
 'att-5-5-fe-correction-approval',
 @vo + 34, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to compare the current day against what is being requested, and understand the consequence before approving.</p>
<h3>Start after</h3><p>5.5-BE Correction Approval</p>
<h3>Also needs (can be stubbed)</h3><p>4.1-FE Approval Settings</p>
<h3>Permission</h3><p><code>attendance.correction-approve</code>, <code>attendance.correction-override-lock</code></p>
<h3>Menu</h3><p><strong>Attendance › Corrections › Approvals</strong></p>
<h3>Related tables</h3><ul><li>correction_requests — see task 5.4-BE.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/corrections/approvals
/attendance/corrections/approvals/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Correction queue</strong> — filters for employee, org unit, status and date range.</li>
<li><strong>Detail view</strong> — a two-column comparison of the day current punches and computed status against the requested values and the projected status; attachment preview; approval step history.</li>
<li><strong>Approve / Reject actions</strong> — reject opens a modal requiring a reason.</li>
<li><strong>Locked-month banner</strong> — shown when the target month is locked, naming the override permission required.</li>
</ul>
<h3>API integration</h3>
<pre>GET  /api/v1/attendance/correction-requests?status=pending
GET  /api/v1/attendance/correction-requests/{id}
POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject
GET  /api/v1/approval-requests/{id}/history</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Open a request</td><td><code>attendance.correction-approve</code></td><td><code>GET /correction-requests/{id}</code></td><td>Two-column current vs requested, <strong>plus the projected resulting status</strong></td><td>—</td></tr>
<tr><td>2</td><td><strong>Approve</strong></td><td>month not locked, <strong>or</strong> caller holds <code>correction-override-lock</code></td><td><code>POST /approval-requests/{id}/approve</code></td><td>Superseding punches inserted, day recalculated</td><td>422 resulting punch sequence invalid → error on the offending time field</td></tr>
<tr><td>3</td><td><strong>Approve</strong> on a locked month</td><td>holds <code>correction-override-lock</code></td><td>same</td><td>An <strong>extra confirmation</strong> naming the consequence fires first</td><td>—</td></tr>
<tr><td>4</td><td><strong>Approve</strong> on a locked month</td><td>lacks the permission</td><td>—</td><td>Button <strong>disabled</strong> with a banner naming the permission needed — not hidden, so the user understands why</td><td>403 never reached</td></tr>
<tr><td>5</td><td><strong>Reject</strong></td><td>always</td><td><code>POST /approval-requests/{id}/reject</code></td><td>Rejected; no punch or record changes</td><td>422 empty reason</td></tr>
<tr><td>6</td><td>View superseded punches</td><td>detail open</td><td>—</td><td>Struck through in the current column, so repeat corrections are visible</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>The comparison shows the projected status after correction, not just the raw times — the approver real question is what this day will become.</li>
<li>Superseded punches from earlier corrections are shown struck through in the current column, so repeat corrections are visible.</li>
<li>On a locked month, an approver without the override permission sees the banner and a disabled Approve button, not a hidden one.</li>
<li>An approver with the override permission gets an extra confirmation step naming the consequence.</li>
<li>A 422 about an invalid resulting punch sequence is rendered against the offending time field.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An approver sees current versus requested and the resulting status side by side.</li>
<li>Locked-month approvals require a deliberate extra confirmation.</li>
<li>An approver lacking the override permission understands why Approve is unavailable.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Reuses ApprovalStatusBadge from task 4.1-FE.</li>
<li>Nav entry under Attendance, Corrections section.</li>
</ul>',
 6.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now);

-- =====================================================================
--  PART F — MONTHLY CLOSING  (4 tasks, 18 points)
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '6.1-BE Monthly Attendance Approval — Backend',
 'att-6-1-be-monthly-approval',
 @vo + 35, @type_be,
 '<h3>Summary</h3><p>As HR, I want to review, approve and lock a month attendance so a payroll-ready summary exists for every employee.</p>
<h3>Start after</h3><p>3.2-BE Daily Summary · 4.1-BE Approval Integration</p>
<h3>Also needs (can be stubbed)</h3><p>5.3a-BE Leave Approval · 5.5-BE Correction Approval</p>
<h3>Permission</h3><p><code>attendance.monthly-view</code>, <code>attendance.monthly-approve</code>, <code>attendance.monthly-unlock</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 6.1-FE</p>
<h3>Related tables</h3><ul><li><code>monthly_attendance_approvals</code> (new)</li></ul>
<h3>DB schema — monthly_attendance_approvals</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>month, year</td><td>tinyint, smallint</td><td>Period</td></tr>
<tr><td>total_present_days</td><td>decimal(7,2)</td><td>Aggregated from attendance_records</td></tr>
<tr><td>total_absent_days</td><td>decimal(7,2)</td><td>Aggregated</td></tr>
<tr><td>total_leave_days</td><td>decimal(7,2)</td><td>Aggregated</td></tr>
<tr><td>total_unpaid_leave_days</td><td>decimal(7,2)</td><td>Leave taken against an unpaid policy; needed for payslip pro-rating</td></tr>
<tr><td>total_half_days</td><td>decimal(7,2)</td><td>Aggregated</td></tr>
<tr><td>total_late_count</td><td>int</td><td>Days with late_minutes above zero</td></tr>
<tr><td>total_overtime_hours</td><td>decimal(7,2)</td><td>Aggregated</td></tr>
<tr><td>total_working_hours</td><td>decimal(7,2)</td><td>Aggregated</td></tr>
<tr><td>unresolved_flag</td><td>boolean default false</td><td>Pending correction/leave, missing check-out, or unassigned day exists</td></tr>
<tr><td>status</td><td>enum(pending, approved, rejected)</td><td>Review state</td></tr>
<tr><td>is_locked</td><td>boolean default false</td><td>Blocks edits once true</td></tr>
<tr><td>ready_for_payroll</td><td>boolean default false</td><td>Payroll reads only when true</td></tr>
<tr><td>approved_by, approved_at</td><td>unsignedBigInteger nullable, datetime nullable</td><td>Approver metadata</td></tr>
<tr><td>frozen_at, frozen_by</td><td>datetime nullable, unsignedBigInteger nullable</td><td>Set by task 6.2-BE</td></tr>
<tr><td>unfreeze_reason</td><td>varchar(255) nullable</td><td>Mandatory when unfreezing</td></tr>
</table>
<p>Keys: unique(company_id, employee_id, month, year) · index(company_id, month, year, status)</p>
<p>total_unpaid_leave_days is separate from total_leave_days because payslip pro-rating needs unpaid days only — paid leave must not reduce earnings.</p>
<h3>API endpoints</h3>
<pre>POST /api/v1/attendance/monthly-attendance/build?month=&amp;year=&amp;employee_id=
GET  /api/v1/attendance/monthly-attendance?month=&amp;year=&amp;status=&amp;department_id=
GET  /api/v1/attendance/monthly-attendance/{id}
GET  /api/v1/attendance/monthly-attendance/{id}/breakdown
POST /api/v1/attendance/monthly-attendance/{id}/approve
POST /api/v1/attendance/monthly-attendance/bulk-approve
POST /api/v1/attendance/monthly-attendance/{id}/unlock
GET  /api/v1/attendance/monthly-attendance?ready_for_payroll=true</pre>
<p>approve and bulk-approve submit through ApprovalGateway; rejection is the platform reject endpoint.</p>
<h3>Business rules</h3>
<ul>
<li>build aggregates each employee month from attendance_records and approved leave, and is idempotent — re-running refreshes a pending row and refuses to touch an approved one.</li>
<li>unresolved_flag is true when the month contains a pending correction, a pending leave request, a missing_check_out day, or an unassigned day.</li>
<li>Approving a month with unresolved_flag = true requires an explicit override true in the request body plus attendance.monthly-approve, and is audit-logged.</li>
<li>Bulk approval operates across a filtered set and reports per-employee success or failure; one blocked employee does not fail the batch.</li>
<li>MonthlyAttendanceExecutor (stub from task 4.1-BE) is implemented here: it sets status approved, is_locked true on every attendance_records row in the month, and ready_for_payroll true.</li>
<li>Rejection requires a reason and leaves the month editable.</li>
<li>unlock requires attendance.monthly-unlock and a mandatory reason; it is <strong>blocked once the month is frozen</strong> (task 6.2-BE) — the freeze is the harder gate.</li>
<li>Emits MonthApproved.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function buildMonthlySummary(employee_id, month, year):
    records = AttendanceRecord.where(employee_id, month, year)

    summary = {
      total_present_days:      count(records where system_code in [present, late]),
      total_absent_days:       count(records where system_code == absent),
      total_leave_days:        count(records where system_code == leave),
      total_unpaid_leave_days: count(records where system_code == leave
                                     and leavePolicyOf(record).is_paid == false),
      total_half_days:         count(records where system_code == half_day),
      total_late_count:        count(records where late_minutes &gt; 0),
      total_overtime_hours:    sum(records.overtime_hours),
      total_working_hours:     sum(records.total_working_hours),
    }

    unresolved = hasPendingCorrection(employee_id, month, year)
              or hasPendingLeave(employee_id, month, year)
              or exists(records where system_code == missing_check_out)
              or hasUnassignedDay(employee_id, month, year)

    if existing and existing.status == APPROVED:
        return existing              # never silently overwrite an approved month

    MonthlyAttendanceApproval.upsert(employee_id, month, year,
        { ...summary, status: PENDING, unresolved_flag: unresolved })</pre>
<h3>Validation</h3>
<ul>
<li>month 1 to 12, year within plus or minus 5 of the current year.</li>
<li>override — boolean, only honoured with attendance.monthly-approve.</li>
<li>Unlock requires a reason, max 255.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Approving an unresolved month without override — 409, response lists the unresolved reasons</li>
<li>Unlocking a frozen month — 409 stating that it must be unfrozen first</li>
<li>Rebuilding an approved month — 409</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can approve a month for one employee or in bulk across a filtered group.</li>
<li>A month with unresolved issues cannot be approved without an explicit override, and the response names each issue.</li>
<li>Approved months are locked and flagged ready_for_payroll; the locked records reject automatic recalculation.</li>
<li>An authorised user can unlock an approved month before it is frozen, with the reason recorded.</li>
<li>Unlock is refused once the month is frozen.</li>
<li>Re-running build on a pending month refreshes it; on an approved month it is refused.</li>
<li>total_unpaid_leave_days counts only leave taken against an unpaid policy.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>MonthlyAttendanceExecutor implemented, registered and tested.</li>
<li>Aggregation unit-tested against a month containing every status, including a paid and an unpaid leave.</li>
<li>Bulk approval partial-failure path tested.</li>
<li>api collection/Attendance/Monthly Approval/*.yml</li>
</ul>',
 16.00, 'todo', 'medium', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '6.1-FE Monthly Attendance Approval — Frontend',
 'att-6-1-fe-monthly-approval',
 @vo + 36, @type_fe,
 '<h3>Summary</h3><p>As HR, I want a month-close grid that shows me exactly which employees are blocked and why, so I can clear issues before approving.</p>
<h3>Start after</h3><p>6.1-BE Monthly Approval</p>
<h3>Also needs (can be stubbed)</h3><p>4.1-FE Approval Settings</p>
<h3>Permission</h3><p><code>attendance.monthly-view</code>, <code>attendance.monthly-approve</code>, <code>attendance.monthly-unlock</code></p>
<h3>Menu</h3><p><strong>Attendance › Monthly Approval</strong></p>
<h3>Related tables</h3><ul><li>monthly_attendance_approvals — see task 6.1-BE.</li></ul>
<h3>Frontend routes</h3><pre>/attendance/monthly-approval
/attendance/monthly-approval/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Monthly grid</strong> — employees as rows, summary columns, month/year and department filters, and an unresolved-issue indicator per row.</li>
<li><strong>Unresolved detail popover</strong> — lists the specific blockers for a row with links to each pending correction, pending leave, or missing-check-out day.</li>
<li><strong>Bulk Approve action</strong> — across the filtered or selected set, followed by a per-employee result table.</li>
<li><strong>Reject action</strong> — modal requiring a reason.</li>
<li><strong>Unlock action</strong> — modal requiring a reason; hidden once the month is frozen.</li>
<li><strong>Employee monthly detail</strong> — day-by-day breakdown behind the summary numbers.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/attendance/monthly-attendance/build?month=&amp;year=
GET  /api/v1/attendance/monthly-attendance?month=&amp;year=
GET  /api/v1/attendance/monthly-attendance/{id}/breakdown
POST /api/v1/attendance/monthly-attendance/{id}/approve
POST /api/v1/attendance/monthly-attendance/bulk-approve
POST /api/v1/attendance/monthly-attendance/{id}/unlock</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Build / rebuild month</strong></td><td><code>attendance.monthly-view</code></td><td><code>POST /monthly-attendance/build</code></td><td>Grid populates or refreshes</td><td>409 on an already-approved month</td></tr>
<tr><td>2</td><td>Click the <strong>unresolved indicator</strong></td><td>row has <code>unresolved_flag</code></td><td>—</td><td>Popover lists the blockers, each deep-linked to the pending correction, pending leave, or missing-check-out day</td><td>—</td></tr>
<tr><td>3</td><td><strong>Approve</strong> one</td><td><code>monthly-approve</code>, row resolved</td><td><code>POST /{id}/approve</code></td><td>Month approved, records locked, <code>ready_for_payroll</code> set</td><td>409 unresolved without override → response lists each reason</td></tr>
<tr><td>4</td><td><strong>Approve with override</strong></td><td><code>monthly-approve</code></td><td>same, <code>override: true</code></td><td>Approved as-is</td><td>Checkbox is <strong>off by default</strong> and states that unresolved issues will be approved as-is</td></tr>
<tr><td>5</td><td><strong>Bulk approve</strong></td><td><code>monthly-approve</code></td><td><code>POST /bulk-approve</code></td><td>Per-employee result table</td><td>Pre-flight count of unresolved rows shown <strong>before</strong> it runs</td></tr>
<tr><td>6</td><td><strong>Reject</strong></td><td><code>monthly-approve</code></td><td><code>POST /approval-requests/{id}/reject</code></td><td>Month stays editable</td><td>422 empty reason</td></tr>
<tr><td>7</td><td><strong>Unlock</strong></td><td><code>monthly-unlock</code> <strong>and</strong> month not frozen</td><td><code>POST /{id}/unlock</code></td><td>Month editable again, reason recorded</td><td>Once frozen the action is <strong>hidden entirely</strong>, not disabled</td></tr>
<tr><td>8</td><td>Click a summary figure</td><td>always</td><td><code>GET /{id}/breakdown</code></td><td>Day-level detail — no number is a dead end</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>The unresolved indicator is actionable, not decorative — clicking it lists the blockers with deep links, because go-fix-it is the only useful next step.</li>
<li>The override checkbox on approval is off by default and states plainly that unresolved issues will be approved as-is.</li>
<li>Bulk approve shows a pre-flight count of how many rows are unresolved before it runs.</li>
<li>The result table after a bulk run separates succeeded from failed rows with per-row reasons.</li>
<li>Frozen months show a distinct badge and hide Unlock entirely rather than disabling it.</li>
<li>Summary columns link into the day-level breakdown, so no number is a dead end.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can see, in one screen, which employees are blocked and open each blocker directly.</li>
<li>Bulk approving a filtered department reports per-employee outcomes.</li>
<li>Overriding unresolved issues requires a deliberate action.</li>
<li>Unlock disappears once a month is frozen.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/monthlyAttendanceApi.ts</code></li>
<li>Nav entry under Attendance, Monthly Approval section.</li>
</ul>',
 10.00, 'todo', 'medium', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '6.2-BE Explicit Monthly Freeze State — Backend',
 'pay-6-2-be-monthly-freeze',
 @vo + 37, @type_be,
 '<h3>Summary</h3><p>As Finance, I want Freeze to be a separately authorised state from Approve, so reviewing attendance and locking it for payroll are enforced as different responsibilities.</p>
<h3>Start after</h3><p>6.1-BE Monthly Approval</p>
<h3>Permission</h3><p><code>payroll.month-freeze</code>, <code>payroll.month-unfreeze-paid</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 6.2-FE</p>
<h3>Related tables</h3>
<ul><li>monthly_attendance_approvals — columns frozen_at, frozen_by and unfreeze_reason are already added in task 6.1-BE.</li></ul>
<h3>API endpoints</h3>
<pre>POST /api/v1/payroll/monthly-attendance/{id}/freeze
POST /api/v1/payroll/monthly-attendance/{id}/unfreeze
GET  /api/v1/payroll/monthly-attendance?frozen=true&amp;month=&amp;year=</pre>
<p>These live in the <strong>Payroll</strong> module because freezing is a Finance responsibility, while approving is HR.</p>
<h3>Business rules</h3>
<ul>
<li>Freeze requires status = approved. A month cannot be frozen before HR has approved it.</li>
<li>payroll.month-freeze is a separate permission from attendance.monthly-approve, and task 0.3-BE guarantees no seeded role holds both.</li>
<li>Freezing publishes MonthFrozen, which is what makes the month eligible for snapshot building (task 8.0-BE).</li>
<li>Unfreezing requires a mandatory reason recorded in unfreeze_reason.</li>
<li>Unfreezing a month whose payroll run is already paid requires payroll.month-unfreeze-paid and is prominently audit-logged.</li>
<li>Approve and Freeze are two separately audit-logged actions, potentially by two different users.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>Freeze — target month status must be approved.</li>
<li>Unfreeze — unfreeze_reason required, max 255.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Freeze on a non-approved month — 409</li>
<li>Unfreeze without a reason — 422</li>
<li>Unfreeze on a month with a paid run, without the elevated permission — 403</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A month must be approved before it can be frozen.</li>
<li>A user holding attendance.monthly-approve but not payroll.month-freeze receives 403 from the freeze endpoint.</li>
<li>Approve and freeze produce two distinct audit entries with their own actors and timestamps.</li>
<li>Unfreezing a paid month requires the elevated permission and is logged.</li>
<li>MonthFrozen is dispatched on freeze and consumed by the snapshot builder.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Permission separation verified by a test asserting that the approve permission alone cannot freeze.</li>
<li>MonthFrozen wired to the snapshot-eligibility check.</li>
<li>api collection/Payroll/Monthly Freeze/*.yml</li>
</ul>',
 6.00, 'todo', 'medium', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '6.2-FE Explicit Monthly Freeze State — Frontend',
 'pay-6-2-fe-monthly-freeze',
 @vo + 38, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want Approved, Frozen and Paid to be visibly different states, so the payroll lock is never confused with HR sign-off.</p>
<h3>Start after</h3><p>6.2-BE Monthly Freeze</p>
<h3>Also needs (can be stubbed)</h3><p>6.1-FE Monthly Approval</p>
<h3>Permission</h3><p><code>payroll.month-freeze</code>, <code>payroll.month-unfreeze-paid</code></p>
<h3>Menu</h3><p><strong>Payroll › Monthly Freeze</strong> — deliberately in the Payroll menu, not beside Monthly Approval, because freezing is a Finance responsibility (segregation of duties)</p>
<h3>Related tables</h3><ul><li>monthly_attendance_approvals — see task 6.1-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/monthly-freeze
/payroll/monthly-freeze/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Four-state status badge</strong> — Pending, Approved, Frozen, Paid, each visually distinct, used on both this screen and the monthly approval grid.</li>
<li><strong>Freeze action</strong> — a separate button from Approve, rendered only for holders of payroll.month-freeze.</li>
<li><strong>Unfreeze action</strong> — reason modal; an additional confirmation step and a strong warning when the run is already paid.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/payroll/monthly-attendance/{id}/freeze
POST /api/v1/payroll/monthly-attendance/{id}/unfreeze
GET  /api/v1/payroll/monthly-attendance?frozen=true</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Freeze</strong></td><td><code>payroll.month-freeze</code> <strong>and</strong> status = <code>approved</code></td><td><code>POST /monthly-attendance/{id}/freeze</code></td><td>State badge → Frozen; month becomes snapshot-eligible</td><td>Disabled with a stated reason until approved; 409 otherwise</td></tr>
<tr><td>2</td><td><strong>Freeze</strong> without the permission</td><td>—</td><td>—</td><td>Control is <strong>not rendered at all</strong> — an HR approver never sees a button they cannot use</td><td>403 never reached</td></tr>
<tr><td>3</td><td><strong>Unfreeze</strong></td><td><code>payroll.month-freeze</code></td><td><code>POST /{id}/unfreeze</code></td><td>Back to Approved, reason recorded</td><td>422 empty reason</td></tr>
<tr><td>4</td><td><strong>Unfreeze a paid month</strong></td><td><code>payroll.month-unfreeze-paid</code></td><td>same</td><td>Requires typing the month name to confirm</td><td>403 without the elevated permission</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>Freeze is disabled with an explanatory tooltip until the month is approved — the reason is stated, not implied.</li>
<li>The Freeze control is not rendered at all for users without the permission, so an HR approver never sees a button they cannot use.</li>
<li>The four states use distinct colour and iconography; Approved and Frozen must not look alike at a glance.</li>
<li>Unfreezing a paid month requires typing the month name to confirm, matching the weight of the action.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A user can distinguish approved-but-not-frozen from frozen at a glance.</li>
<li>Freeze is invisible to users lacking payroll.month-freeze.</li>
<li>Unfreezing a paid month cannot happen without a deliberate confirmation.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Shared MonthStateBadge component reused by task 6.1-FE and the 8.x screens.</li>
<li>Nav entry under Payroll.</li>
</ul>',
 4.00, 'todo', 'medium', @s7, NULL, NULL, @now, @now);

-- =====================================================================
--  PART G — PAYROLL CONFIGURATION  (14 tasks, 56 points)
--  Depends only on Part 0; can run in parallel with Parts A to F.
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_payroll,
 '7.0-BE Payroll Settings — Backend',
 'pay-7-0-be-payroll-settings',
 @vo + 39, @type_be,
 '<h3>Summary</h3><p>As Finance, I want the overtime multiplier, hourly-rate basis, pro-rating method and salary-advance policy configured per company, because payslip generation cannot be correct without them and not every company offers advances.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>payroll.settings-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.0-FE</p>
<h3>Related tables</h3><ul><li><code>payroll_settings</code> (new)</li></ul>
<h3>DB schema — payroll_settings</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Unique — one settings row per company</td></tr>
<tr><td>overtime_multiplier</td><td>decimal(4,2) default 1.50</td><td>Multiplier applied to the hourly rate</td></tr>
<tr><td>overtime_rate_base</td><td>enum(gross, basic) default basic</td><td>Which figure the hourly rate derives from</td></tr>
<tr><td>standard_monthly_hours</td><td>decimal(6,2) default 208</td><td>Divisor for the fixed-hours method</td></tr>
<tr><td>hourly_rate_method</td><td>enum(fixed_monthly_hours, working_days_x_shift_hours) default fixed_monthly_hours</td><td>How the hourly rate is derived</td></tr>
<tr><td>prorate_method</td><td>enum(working_days, calendar_days) default working_days</td><td>Basis for unpaid-day pro-rating</td></tr>
<tr><td>round_net_pay_to</td><td>decimal(4,2) default 1.00</td><td>Rounding step for net pay</td></tr>
<tr><td>advance_enabled</td><td>boolean default false</td><td><strong>Company-wise switch</strong> — off hides the advance menu and 409s its endpoints</td></tr>
<tr><td>advance_default_method</td><td>enum(fixed, percentage) default percentage</td><td>Prefills the request form</td></tr>
<tr><td>advance_default_value</td><td>decimal(12,2) default 50.00</td><td>Amount, or percent (50.00 = 50%)</td></tr>
<tr><td>advance_max_percentage</td><td>decimal(5,2) default 50.00</td><td>Ceiling on the month total as a share of gross</td></tr>
<tr><td>advance_max_amount</td><td>decimal(12,2) nullable</td><td>Absolute ceiling; null means no absolute cap</td></tr>
<tr><td>advance_requires_approval</td><td>boolean default true</td><td>Off lets payroll.advance-manage holders approve directly</td></tr>
<tr><td>updated_by</td><td>unsignedBigInteger nullable</td><td>Actor</td></tr>
</table>
<p>Keys: unique(company_id)</p>
<h3>API endpoints</h3>
<pre>GET /api/v1/payroll/settings
PUT /api/v1/payroll/settings</pre>
<h3>Business rules</h3>
<ul>
<li>The row is created with defaults on first read; there is no create endpoint.</li>
<li>Hourly rate, used by payslip generation (task 8.2a-BE):</li>
</ul>
<pre>base        = overtime_rate_base == "basic" ? salary.basic_salary : salary.gross_salary
hourly_rate = hourly_rate_method == "fixed_monthly_hours"
              ? base / standard_monthly_hours
              : base / (working_days_in_month * shift.working_hours)</pre>
<ul>
<li>Changing settings affects <strong>future</strong> payslip generation only. Already-generated payslips are never recalculated by a settings change; regenerating a draft picks up the new values, and this is stated in the response.</li>
<li>The original task-card document referenced OT_MULTIPLIER and hourlyRate() without defining either. This task is where they are defined.</li>
<li><strong>Salary advance policy.</strong> advance_enabled is the company-wise switch: some companies pay part of a month salary early when payroll runs late, most do not. When it is false, every task 7.5-BE endpoint returns 409 and the nav item is hidden.</li>
<li>The two ceilings are checked against the <strong>month running total</strong> of an employee advances, not against a single request — see task 7.5-BE. This task only stores them.</li>
<li>Turning advance_enabled off does not delete or invalidate advances already recorded; they still settle on their payslips. It only blocks new ones.</li>
<li>advance_requires_approval = false is a company policy bypass and is deliberately separate from approval_settings.approval_enabled, which is the platform bypass. Either being off produces an immediately-approved advance.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>overtime_multiplier — between 0 and 5.</li>
<li>standard_monthly_hours — greater than 0, at most 744.</li>
<li>round_net_pay_to — one of 0.01, 0.10, 1.00, 10.00.</li>
<li>overtime_rate_base = basic requires that structures in use define a basic component; validated as a warning, not a block.</li>
<li>advance_max_percentage — greater than 0 and at most 100.</li>
<li>advance_max_amount — greater than 0 when present.</li>
<li>advance_default_value — greater than 0; at most 100 when advance_default_method is percentage.</li>
<li>advance_default_value must not itself exceed advance_max_percentage or advance_max_amount, otherwise the prefilled form would always fail the ceiling.</li>
</ul>
<h3>Error handling</h3>
<ul><li>Update by a caller without payroll.settings-manage — 403</li></ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A first GET returns a settings row populated with the documented defaults.</li>
<li>Updating the multiplier changes overtime on a newly generated payslip and leaves existing finalized payslips untouched.</li>
<li>Both hourly-rate methods are unit-tested against the same salary and produce the expected different figures.</li>
<li>A company with advance_enabled = false receives 409 from every task 7.5-BE endpoint.</li>
<li>Setting advance_default_value = 60 with advance_max_percentage = 50 returns 422.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>hourlyRate() implemented as a single shared service method consumed by task 8.2a-BE — not duplicated in the generator.</li>
<li>advance_enabled exposed on the settings resource so the frontend can gate its nav item from one read.</li>
<li>api collection/Payroll/Settings/*.yml</li>
</ul>',
 6.00, 'todo', 'medium', @s3, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.0-FE Payroll Settings — Frontend',
 'pay-7-0-fe-payroll-settings',
 @vo + 40, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want a settings screen that shows me the effect of each option on a worked example, so the configuration is not guesswork, and one switch that turns salary advances on or off for the company.</p>
<h3>Start after</h3><p>7.0-BE Payroll Settings</p>
<h3>Permission</h3><p><code>payroll.settings-manage</code></p>
<h3>Menu</h3><p><strong>Payroll › Configuration › Payroll Settings</strong></p>
<h3>Related tables</h3><ul><li>payroll_settings — see task 7.0-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/settings</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Settings form</strong> — overtime group, hourly-rate group, pro-rating group, rounding.</li>
<li><strong>Salary advance group</strong> — the advance_enabled master switch, and beneath it the default method, default value, the two ceilings and the approval-required toggle.</li>
<li><strong>Worked example panel</strong> — a sample salary with the current settings applied, showing the derived hourly rate, one hour of overtime, a one-unpaid-day pro-rated gross, and the maximum advance the ceilings would permit on that salary.</li>
</ul>
<h3>API integration</h3>
<pre>GET /api/v1/payroll/settings
PUT /api/v1/payroll/settings</pre>
<h3>UI rules</h3>
<ul>
<li>The worked example recomputes live as fields change, before saving — this is the screen main value.</li>
<li>standard_monthly_hours is hidden when the method is working_days_x_shift_hours, since it is unused there.</li>
<li>The whole advance group collapses to a single switch when advance_enabled is off — a company that does not offer advances never sees the ceilings.</li>
<li>Turning the switch off warns that existing recorded advances still settle on their payslips and only new requests are blocked, so nobody expects it to undo anything.</li>
<li>A save banner states plainly that changes apply to future generation only and lists how many draft payroll runs would be affected on regeneration.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can see the numeric effect of each setting before saving.</li>
<li>Irrelevant fields are hidden rather than disabled.</li>
<li>The forward-only impact of a change is stated on save.</li>
<li>Toggling the advance switch shows or hides the entire Salary Advances nav item without a page reload.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/payrollSettingsApi.ts</code></li>
<li>Nav entry under Payroll.</li>
</ul>',
 6.00, 'todo', 'medium', @s3, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.1-BE Salary Structure Management — Backend',
 'pay-7-1-be-salary-structures',
 @vo + 41, @type_be,
 '<h3>Summary</h3><p>As HR, I want to manage named salary structures as templates that group employees for component assignment.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>payroll.structure-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.1-FE</p>
<h3>Related tables</h3><ul><li><code>salary_structures</code> — <strong>existing table, no migration required</strong></li></ul>
<h3>DB schema — salary_structures (existing, aligned to the shipped migration)</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint unsigned PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>bigint unsigned nullable</td><td>Nullable allows a system-wide default structure</td></tr>
<tr><td>name</td><td>varchar(150)</td><td>Structure name</td></tr>
<tr><td>code</td><td>varchar(50)</td><td>Unique per company_id</td></tr>
<tr><td>requires_basic</td><td>boolean default true</td><td>Whether a Basic component is mandatory</td></tr>
<tr><td>status</td><td>varchar(20) default Active</td><td>Active / Inactive</td></tr>
<tr><td>created_by, updated_by</td><td>bigint unsigned nullable</td><td>Audit columns</td></tr>
</table>
<p>Existing keys: unique(company_id, code) · index(company_id, status)</p>
<h3>API endpoints</h3>
<pre>GET   /api/v1/payroll/salary-structures
POST  /api/v1/payroll/salary-structures
GET   /api/v1/payroll/salary-structures/{id}
PUT   /api/v1/payroll/salary-structures/{id}
PATCH /api/v1/payroll/salary-structures/{id}/activate
PATCH /api/v1/payroll/salary-structures/{id}/deactivate</pre>
<h3>Business rules</h3>
<ul>
<li><strong>Ownership decision:</strong> the Payroll module owns write access to salary structures. The existing read-only endpoints under /api/v1/configuration/salary-structures remain for pickers. Do not add a second write path, and do not move the migration.</li>
<li>salary_structures is a named container only. Its earning and deduction components live in salary_structure_components (task 7.2-BE).</li>
<li>code is unique per company.</li>
<li>A structure with requires_basic = true cannot be activated until exactly one component with is_basic = true exists.</li>
<li>Deactivating a structure blocks new employee_salaries assignments against it but leaves existing assignments untouched.</li>
<li>The list endpoint returns a component count and a ready_to_activate boolean so the UI can explain blocked activation without a second call.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>name, code — required; code unique per company_id.</li>
<li>requires_basic — boolean.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Duplicate code within company — 409</li>
<li>Activate without a basic component while requires_basic = true — 422 naming the missing component</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can create a structure with a code unique to their company.</li>
<li>A requires_basic structure cannot be activated until a basic component exists, and the error says so.</li>
<li>Deactivated structures are absent from new salary-assignment pickers but remain on existing employee_salaries rows.</li>
<li>No migration was added for this table.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Implemented against the existing schema with <strong>no migration</strong>.</li>
<li>ready_to_activate derivation unit-tested.</li>
<li>api collection/Payroll/Salary Structures/*.yml</li>
</ul>',
 6.00, 'todo', 'medium', @s3, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.1-FE Salary Structure Management — Frontend',
 'pay-7-1-fe-salary-structures',
 @vo + 42, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to manage salary structure templates and see at a glance whether each one is complete enough to activate.</p>
<h3>Start after</h3><p>7.1-BE Salary Structures</p>
<h3>Permission</h3><p><code>payroll.structure-manage</code></p>
<h3>Menu</h3><p><strong>Payroll › Configuration › Salary Structures</strong></p>
<h3>Related tables</h3><ul><li>salary_structures — see task 7.1-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/salary-structures
/payroll/salary-structures/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Structure list</strong> — name, code, component count, readiness indicator, status.</li>
<li><strong>Add / Edit form</strong> — name, code, requires_basic toggle.</li>
<li><strong>Structure detail</strong> — summary plus a link into its components (task 7.2-FE).</li>
</ul>
<h3>API integration</h3>
<pre>GET   /api/v1/payroll/salary-structures
POST  /api/v1/payroll/salary-structures
GET   /api/v1/payroll/salary-structures/{id}
PUT   /api/v1/payroll/salary-structures/{id}
PATCH /api/v1/payroll/salary-structures/{id}/activate
PATCH /api/v1/payroll/salary-structures/{id}/deactivate</pre>
<h3>UI rules</h3>
<ul>
<li>Activate is disabled with a tooltip naming the missing basic component when ready_to_activate is false — the blocker is stated, never implied.</li>
<li>The readiness indicator appears in the list, so HR does not have to open each structure to find the incomplete one.</li>
<li>The requires_basic toggle warns when switched on for a structure that has no basic component yet.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can see from the list which structures are ready to activate.</li>
<li>A blocked activation explains exactly what is missing.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/salaryStructureApi.ts</code></li>
<li>Nav entry under Payroll.</li>
</ul>',
 4.00, 'todo', 'medium', @s3, NULL, NULL, @now, @now);

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_payroll,
 '7.2-BE Salary Structure Components — Backend',
 'pay-7-2-be-structure-components',
 @vo + 43, @type_be,
 '<h3>Summary</h3><p>As HR, I want to define the individual earning and deduction components under a salary structure, because the existing salary_structures table has nowhere to store them.</p>
<h3>Start after</h3><p>7.1-BE Salary Structures</p>
<h3>Permission</h3><p><code>payroll.structure-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.2-FE</p>
<h3>Related tables</h3>
<ul><li><code>salary_structure_components</code> (new)</li><li>salary_structures (existing)</li></ul>
<h3>DB schema — salary_structure_components</h3>
<p>New; this closes the gap found when the shipped schema was reviewed.</p>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint unsigned PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>salary_structure_id</td><td>unsignedBigInteger</td><td>salary_structures.id</td></tr>
<tr><td>component_name</td><td>varchar(100)</td><td>Basic, House Rent, Conveyance, Provident Fund</td></tr>
<tr><td>component_code</td><td>varchar(50)</td><td>Unique per structure; the key used in payslip breakdown JSON</td></tr>
<tr><td>component_type</td><td>enum(earning, deduction)</td><td>Adds to or subtracts from gross</td></tr>
<tr><td>is_basic</td><td>boolean default false</td><td>At most one per structure</td></tr>
<tr><td>calculation_type</td><td>enum(fixed, percentage)</td><td>Fixed amount or percentage of a base</td></tr>
<tr><td>value</td><td>decimal(12,2)</td><td>Fixed amount, or percentage value (25.00 means 25 percent)</td></tr>
<tr><td>percentage_base</td><td>enum(gross, basic) nullable</td><td>Required when calculation_type = percentage</td></tr>
<tr><td>is_taxable</td><td>boolean default true</td><td>Included in the taxable base (task 7.3-BE)</td></tr>
<tr><td>prorated</td><td>boolean default true</td><td>Whether unpaid days reduce this component</td></tr>
<tr><td>display_order</td><td>int default 0</td><td>Order on the payslip</td></tr>
<tr><td>status</td><td>varchar(20) default Active</td><td>Active / Inactive</td></tr>
</table>
<p>Keys: unique(salary_structure_id, component_code) · index(company_id, salary_structure_id)</p>
<p>prorated exists because a fixed reimbursement should not shrink with absence, while a salary component should. Pro-rating every earning unconditionally is wrong for allowances.</p>
<h3>API endpoints</h3>
<pre>GET    /api/v1/payroll/salary-structures/{id}/components
POST   /api/v1/payroll/salary-structures/{id}/components
PUT    /api/v1/payroll/salary-structure-components/{id}
DELETE /api/v1/payroll/salary-structure-components/{id}
PATCH  /api/v1/payroll/salary-structures/{id}/components/reorder
POST   /api/v1/payroll/salary-structures/{id}/components/preview</pre>
<p>preview returns a sample payslip breakdown for a supplied gross salary, without persisting anything.</p>
<h3>Business rules</h3>
<ul>
<li>A structure with requires_basic = true must contain exactly one component with is_basic = true.</li>
<li>percentage_base is required when calculation_type = percentage and ignored when fixed.</li>
<li>The sum of percentage earnings against the same base exceeding 100 percent is a <strong>soft warning</strong> in the response, not a block — fixed top-ups can legitimately coexist.</li>
<li>Payslip generation (task 8.2a-BE) reads components from this table. There is no JSON component field anywhere.</li>
<li>component_code is the key used in earnings_breakdown and deductions_breakdown, so it must be stable; it cannot be changed once any payslip references the structure.</li>
<li>Deleting the sole basic component of a requires_basic structure is blocked.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>component_name, component_code, component_type, calculation_type, value — required.</li>
<li>component_code — uppercase alphanumeric plus underscore, unique per structure.</li>
<li>value — greater than 0.</li>
<li>percentage_base — required when calculation_type = percentage.</li>
<li>is_basic = true — allowed only when component_type = earning, and only once per structure.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Deleting the sole basic component while requires_basic = true — 409</li>
<li>Second is_basic component — 422</li>
<li>Missing percentage_base on a percentage component — 422</li>
<li>Changing component_code on a structure referenced by a payslip — 409</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can add, edit, reorder and remove components under a structure.</li>
<li>Removing the last basic component from a requires_basic structure is blocked with a clear message.</li>
<li>A percentage component without a base is rejected.</li>
<li>Percentage earnings over 100 percent of the same base return a warning and still save.</li>
<li>Payslip generation reads from this table, verified by an integration test in task 8.2a-BE.</li>
<li>A non-prorated component keeps its full value on a payslip with unpaid days.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for salary_structure_components.</li>
<li>preview and the real generator share one calculation path for the earnings section.</li>
<li>api collection/Payroll/Salary Structure Components/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s4, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.2-FE Salary Structure Components — Frontend',
 'pay-7-2-fe-structure-components',
 @vo + 44, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to build a structure components on one screen with a live payslip preview, so the result is visible before anyone is paid by it.</p>
<h3>Start after</h3><p>7.2-BE Structure Components</p>
<h3>Permission</h3><p><code>payroll.structure-manage</code></p>
<h3>Menu</h3><p><strong>Payroll › Configuration › Salary Structures</strong> — embedded builder on the structure detail (7.1-FE), no own nav item</p>
<h3>Related tables</h3><ul><li>salary_structure_components — see task 7.2-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/salary-structures/{id}/components</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Component builder</strong> — repeatable rows with name, code, type, calculation type, value, percentage base, taxable flag, prorated flag, and drag-to-reorder.</li>
<li><strong>Live preview panel</strong> — a sample payslip breakdown for an editable sample gross, updating as components change.</li>
<li><strong>Warning banner</strong> — appears when percentage components exceed 100 percent of their base.</li>
</ul>
<h3>API integration</h3>
<pre>GET    /api/v1/payroll/salary-structures/{id}/components
POST   /api/v1/payroll/salary-structures/{id}/components
PUT    /api/v1/payroll/salary-structure-components/{id}
DELETE /api/v1/payroll/salary-structure-components/{id}
PATCH  /api/v1/payroll/salary-structures/{id}/components/reorder
POST   /api/v1/payroll/salary-structures/{id}/components/preview</pre>
<h3>UI rules</h3>
<ul>
<li>The percentage-base field is rendered only for percentage components.</li>
<li>The preview comes from the preview endpoint, not client-side maths, so it cannot drift from the real generator.</li>
<li>Earnings and deductions are visually grouped and separately subtotalled, matching the payslip layout.</li>
<li>The prorated flag carries a one-line explanation of its effect, because its meaning is not obvious from the label.</li>
<li>component_code becomes read-only once the API reports the structure is referenced by a payslip, with a tooltip explaining why.</li>
<li>The over-100-percent warning is non-blocking and dismissible, since it is legitimate in some structures.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can build a full component set and see the resulting payslip before saving.</li>
<li>The preview matches what generation later produces for the same gross.</li>
<li>Reordering changes the preview and the persisted display order.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/salaryStructureComponentApi.ts</code></li>
<li>Drag-to-reorder with optimistic update and rollback.</li>
</ul>',
 10.00, 'todo', 'medium', @s4, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.3-BE Tax Slab Configuration — Backend',
 'pay-7-3-be-tax-slabs',
 @vo + 45, @type_be,
 '<h3>Summary</h3><p>As Finance, I want to configure income tax slabs so payroll calculates statutory deductions correctly.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>payroll.settings-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.3-FE</p>
<h3>Related tables</h3><ul><li><code>tax_slabs</code> (new)</li></ul>
<h3>DB schema — tax_slabs</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>name</td><td>varchar(150)</td><td>Slab set name, e.g. FY2026 Individual</td></tr>
<tr><td>effective_year</td><td>smallint</td><td>Tax year this applies to</td></tr>
<tr><td>slabs</td><td>json</td><td>Ordered array of min_income, max_income, rate; max_income null on the top bracket</td></tr>
<tr><td>status</td><td>varchar(20) default Inactive</td><td>Active / Inactive</td></tr>
<tr><td>created_by, updated_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
</table>
<p>Keys: index(company_id, effective_year, status)</p>
<p>One-Active-set-per-year cannot be expressed as a database unique key alongside multiple inactive rows, so it is enforced in the service under a transaction.</p>
<h3>API endpoints</h3>
<pre>GET   /api/v1/payroll/tax-slabs?effective_year=
POST  /api/v1/payroll/tax-slabs
GET   /api/v1/payroll/tax-slabs/{id}
PUT   /api/v1/payroll/tax-slabs/{id}
PATCH /api/v1/payroll/tax-slabs/{id}/status
POST  /api/v1/payroll/tax-slabs/calculate</pre>
<p>calculate returns the tax for a supplied annual income against a given slab set — used by the UI preview and by tests.</p>
<h3>Business rules</h3>
<ul>
<li>A slab set defines income brackets and rates for one effective year.</li>
<li><strong>Brackets must be contiguous, non-overlapping and ascending, with exactly one open-ended top bracket.</strong> A gap between brackets silently under-taxes, which is why this is validated rather than assumed.</li>
<li>Only one Active set per company per effective year. Activating a second one deactivates the first inside the same transaction.</li>
<li>The Active set for the run year is used during payslip generation (task 8.2a-BE).</li>
<li>A set referenced by any generated payslip cannot be deleted or have its slabs edited — create a new set instead. Renaming remains allowed.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function calculateSlabTax(annual_income, slabs):
    tax = 0
    for slab in slabs order by min_income asc:
        if annual_income &lt;= slab.min_income:
            break
        upper           = slab.max_income ?? annual_income
        taxable_in_band = min(annual_income, upper) - slab.min_income
        tax            += taxable_in_band * slab.rate / 100
    return tax

# slabs 0-350000 at 0 pct, 350000-700000 at 10 pct, 700000+ at 15 pct
# income 720000
#   band 1: (350000 - 0)      * 0 pct  = 0
#   band 2: (700000 - 350000) * 10 pct = 35000
#   band 3: (720000 - 700000) * 15 pct = 3000
#   total                              = 38000</pre>
<h3>Validation</h3>
<ul>
<li>name, effective_year, slabs — required.</li>
<li>slabs — at least one entry; first min_income is 0; each subsequent min_income equals the previous max_income; exactly one entry has max_income null and it is the last; every rate between 0 and 100.</li>
<li>effective_year — within plus or minus 5 years of the current year.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Non-contiguous or overlapping brackets — 422 naming the offending pair</li>
<li>More than one open-ended bracket, or an open-ended bracket that is not last — 422</li>
<li>Activating a second set for the same year — succeeds, deactivating the first; the response says so</li>
<li>Editing slabs on a referenced set — 409</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can define a set with multiple brackets and rates.</li>
<li>A set with a gap between brackets is rejected with the offending pair named.</li>
<li>Activating a new set for a year deactivates the previous one automatically.</li>
<li>calculate returns 38000 for the worked example above.</li>
<li>A set used by a generated payslip cannot have its brackets edited.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>calculateSlabTax implemented once as a shared service method, consumed by task 8.2a-BE.</li>
<li>Bracket-continuity validation unit-tested against gaps, overlaps and a misplaced open-ended band.</li>
<li>api collection/Payroll/Tax Slabs/*.yml</li>
</ul>',
 6.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.3-FE Tax Slab Configuration — Frontend',
 'pay-7-3-fe-tax-slabs',
 @vo + 46, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want to build tax brackets with immediate feedback on continuity and a live tax calculation, so a mis-entered bracket never reaches payroll.</p>
<h3>Start after</h3><p>7.3-BE Tax Slabs</p>
<h3>Permission</h3><p><code>payroll.settings-manage</code></p>
<h3>Menu</h3><p><strong>Payroll › Configuration › Tax Slabs</strong></p>
<h3>Related tables</h3><ul><li>tax_slabs — see task 7.3-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/tax-slabs
/payroll/tax-slabs/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Slab list</strong> — grouped by effective year, with the Active set marked.</li>
<li><strong>Add / Edit form</strong> — repeatable bracket rows (min, max, rate) with add, remove and automatic ordering.</li>
<li><strong>Continuity indicator</strong> — a visual band chart showing coverage and highlighting any gap or overlap.</li>
<li><strong>Tax preview</strong> — an income input showing the computed tax and the per-band contribution.</li>
<li><strong>Activate toggle</strong> — with a confirmation naming the set it will replace.</li>
</ul>
<h3>API integration</h3>
<pre>GET   /api/v1/payroll/tax-slabs?effective_year=
POST  /api/v1/payroll/tax-slabs
PUT   /api/v1/payroll/tax-slabs/{id}
PATCH /api/v1/payroll/tax-slabs/{id}/status
POST  /api/v1/payroll/tax-slabs/calculate</pre>
<h3>UI rules</h3>
<ul>
<li>Each bracket min_income auto-fills from the previous row max_income and is read-only, which makes gaps structurally impossible in the common path.</li>
<li>The last row max_income is fixed as and-above and cannot be given a value.</li>
<li>The band chart marks any gap in red before submission — the failure mode this guards against is silent under-taxation.</li>
<li>The tax preview shows the per-band breakdown, not just a total, so the figure is checkable by hand.</li>
<li>Activating a set states which set will be deactivated, by name.</li>
<li>Bracket fields are read-only on a referenced set, with an explanation and a duplicate-as-new-set action.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can build a complete bracket set without being able to leave a gap.</li>
<li>The preview per-band figures match the backend calculate response.</li>
<li>Activating a set clearly states what it replaces.</li>
<li>A referenced set offers duplication instead of editing.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/taxSlabApi.ts</code></li>
<li>Nav entry under Payroll.</li>
</ul>',
 6.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.4-BE Employee Deductions & Loans — Backend',
 'pay-7-4-be-employee-deductions',
 @vo + 47, @type_be,
 '<h3>Summary</h3><p>As HR, I want to track recurring deductions and loans per employee so they are applied automatically across payroll runs, exactly once each.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>0.3-BE Permission Registry</p>
<h3>Permission</h3><p><code>payroll.deduction-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.4-FE</p>
<h3>Related tables</h3>
<ul><li><code>employee_deductions</code> (new)</li><li><code>employee_deduction_entries</code> (new)</li></ul>
<h3>DB schema — employee_deductions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>type</td><td>enum(loan, advance, fine, other)</td><td>Kind of deduction</td></tr>
<tr><td>total_amount</td><td>decimal(12,2)</td><td>Original amount</td></tr>
<tr><td>remaining_balance</td><td>decimal(12,2)</td><td>Amount still outstanding</td></tr>
<tr><td>installment_amount</td><td>decimal(12,2)</td><td>Per-run deduction</td></tr>
<tr><td>start_month, start_year</td><td>tinyint, smallint</td><td>When deduction begins</td></tr>
<tr><td>status</td><td>enum(active, completed, cancelled)</td><td>Lifecycle</td></tr>
<tr><td>remarks</td><td>text nullable</td><td>Free-text note</td></tr>
<tr><td>created_by, updated_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
</table>
<p>Keys: index(company_id, employee_id, status)</p>
<h3>DB schema — employee_deduction_entries</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>employee_deduction_id</td><td>unsignedBigInteger</td><td>Parent deduction</td></tr>
<tr><td>payroll_run_id</td><td>unsignedBigInteger</td><td>Which run applied it</td></tr>
<tr><td>payslip_id</td><td>unsignedBigInteger nullable</td><td>Which payslip carried it</td></tr>
<tr><td>amount</td><td>decimal(12,2)</td><td>Applied amount</td></tr>
<tr><td>created_at</td><td>timestamp</td><td>When</td></tr>
</table>
<p>Keys: unique(employee_deduction_id, payroll_run_id)</p>
<p>Without the entries table, regenerating a draft payslip decrements remaining_balance a second time. The unique key makes application idempotent per run.</p>
<h3>API endpoints</h3>
<pre>GET   /api/v1/payroll/employee-deductions?employee_id=&amp;status=
POST  /api/v1/payroll/employee-deductions
GET   /api/v1/payroll/employee-deductions/{id}
PATCH /api/v1/payroll/employee-deductions/{id}
POST  /api/v1/payroll/employee-deductions/{id}/cancel
GET   /api/v1/payroll/employee-deductions/{id}/entries</pre>
<h3>Business rules</h3>
<ul>
<li>HR records a deduction or loan with a type, total, installment and start period.</li>
<li>installment_amount is applied to each eligible payroll run from the start period until remaining_balance reaches zero, at which point status becomes completed.</li>
<li><strong>Application is idempotent per run.</strong> Regenerating a payslip reverses that run existing entry before re-applying, so the balance never double-decrements.</li>
<li>If applying an installment would drive net pay below zero, it is skipped, the payslip is flagged needs_review, and the balance is unchanged.</li>
<li>Cancelling stops future installments and does not alter already-generated payslips.</li>
<li>PATCH may change installment_amount and remarks only; changes take effect from the next run.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function applyInstallment(deduction, run, projected_net):
    if deduction.status != ACTIVE:                 return 0
    if beforeStartPeriod(deduction, run):          return 0

    existing = DeductionEntry.find(deduction.id, run.id)
    if existing:
        deduction.remaining_balance += existing.amount     # reverse before re-applying
        existing.delete()

    amount = min(deduction.installment_amount, deduction.remaining_balance)
    if amount &lt;= 0:                                return 0

    if projected_net - amount &lt; 0:
        flagForHrReview(deduction, run)
        return 0

    transaction:
        deduction.remaining_balance -= amount
        if deduction.remaining_balance &lt;= 0:
            deduction.status = COMPLETED
        deduction.save()
        DeductionEntry.create({ deduction, run, amount })

    return amount</pre>
<h3>Validation</h3>
<ul>
<li>type, total_amount, installment_amount, start_month, start_year — required.</li>
<li>total_amount and installment_amount — greater than 0; installment not greater than total.</li>
<li>start_month 1 to 12.</li>
<li>remaining_balance is set to total_amount on creation and is not directly writable.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>installment_amount greater than total_amount — 422</li>
<li>Editing a completed or cancelled deduction — 409</li>
<li>Attempting to write remaining_balance directly — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A loan created with a total and a monthly installment appears in subsequent payslips automatically.</li>
<li>The remaining balance decreases by exactly one installment per run, even when the payslip is regenerated three times.</li>
<li>A deduction reaching zero is marked completed and stops appearing.</li>
<li>Cancelling stops future installments and leaves generated payslips untouched.</li>
<li>An installment that would push net pay negative is skipped and the payslip is flagged.</li>
<li>The entries endpoint shows one row per run, matching the balance history.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for both tables.</li>
<li>Idempotency test: generate, regenerate twice, assert a single balance decrement.</li>
<li>Negative-net-pay skip path tested.</li>
<li>api collection/Payroll/Employee Deductions/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.4-FE Employee Deductions & Loans — Frontend',
 'pay-7-4-fe-employee-deductions',
 @vo + 48, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to see each loan repayment progress and the runs it was applied in, so a disputed deduction can be answered from one screen.</p>
<h3>Start after</h3><p>7.4-BE Deductions &amp; Loans</p>
<h3>Permission</h3><p><code>payroll.deduction-manage</code></p>
<h3>Menu</h3><p><strong>Payroll › Deductions &amp; Loans</strong></p>
<h3>Related tables</h3><ul><li>employee_deductions, employee_deduction_entries — see task 7.4-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/employee-deductions
/payroll/employee-deductions/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Deduction list</strong> — employee, type, total, remaining, a paid-versus-remaining progress bar, status.</li>
<li><strong>Add form</strong> — type, total amount, installment amount, start period, remarks, with a derived N-installments-completing-in line.</li>
<li><strong>Detail view</strong> — the deduction plus its per-run entry history with links to each payslip.</li>
<li><strong>Cancel action</strong> — confirmation stating that generated payslips are unaffected.</li>
</ul>
<h3>API integration</h3>
<pre>GET   /api/v1/payroll/employee-deductions?employee_id=&amp;status=
POST  /api/v1/payroll/employee-deductions
GET   /api/v1/payroll/employee-deductions/{id}
PATCH /api/v1/payroll/employee-deductions/{id}
POST  /api/v1/payroll/employee-deductions/{id}/cancel
GET   /api/v1/payroll/employee-deductions/{id}/entries</pre>
<h3>UI rules</h3>
<ul>
<li>The projected completion month is computed and shown while composing, so an unrealistic installment is obvious before saving.</li>
<li>The entry history links each applied amount to its payslip — this is the screen HR opens when an employee queries a deduction.</li>
<li>Edit exposes only the installment amount and remarks; the total and start period are read-only after creation, with an explanation.</li>
<li>Cancel states explicitly that past payslips are not altered, so nobody expects a refund from it.</li>
<li>A deduction skipped in a run because of negative net pay is flagged in the entry history with the reason.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can see repayment progress at a glance across all employees.</li>
<li>Every applied installment links to the payslip that carried it.</li>
<li>A skipped installment is visible with its reason.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/employeeDeductionApi.ts</code></li>
<li>Nav entry under Payroll.</li>
</ul>',
 6.00, 'todo', 'medium', @s5, NULL, NULL, @now, @now),
(@project_id, @mod_payroll,
 '7.5-BE Salary Advances — Backend',
 'pay-7-5-be-salary-advances',
 @vo + 49, @type_be,
 '<h3>Summary</h3><p>As HR, I want to record a part-payment of an employee own salary when payroll runs late, so the employee gets money now and that month payslip pays only the balance.</p>
<h3>Start after</h3><p>7.0-BE Payroll Settings · 4.1-BE Approval Integration</p>
<h3>Permission</h3><p><code>payroll.advance-manage</code>, approval on <code>payroll.advance-approve</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.5-FE</p>
<h3>Related tables</h3>
<ul>
<li><code>salary_advances</code> (new)</li>
<li><code>payroll_settings</code> — the company policy, see task 7.0-BE</li>
<li><code>employee_salaries</code> — the priced-from salary row</li>
<li><code>payslips</code> — settlement target, see task 8.2b-BE</li>
</ul>
<h3>DB schema — salary_advances</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>month, year</td><td>tinyint, smallint</td><td>The payroll period this is advanced against</td></tr>
<tr><td>employee_salary_id</td><td>unsignedBigInteger</td><td>The salary row it was priced from</td></tr>
<tr><td>request_method</td><td>enum(fixed, percentage)</td><td>How the amount was expressed</td></tr>
<tr><td>requested_value</td><td>decimal(12,2)</td><td>Amount, or percent (50.00 = 50%)</td></tr>
<tr><td>basis_gross</td><td>decimal(12,2)</td><td>employee_salaries.gross_salary <strong>frozen at request time</strong></td></tr>
<tr><td>amount</td><td>decimal(12,2)</td><td>Resolved payable amount</td></tr>
<tr><td>status</td><td>enum(draft, pending_approval, approved, rejected, paid, settled, cancelled)</td><td>Lifecycle</td></tr>
<tr><td>reason</td><td>text nullable</td><td>Why the advance was requested</td></tr>
<tr><td>payment_channel</td><td>enum(cash, cheque, bank, mobile_banking) nullable</td><td>How HR handed the money over</td></tr>
<tr><td>payment_reference</td><td>varchar(100) nullable</td><td>Cheque no. / txn id / voucher no.</td></tr>
<tr><td>paid_at, paid_by</td><td>datetime, unsignedBigInteger nullable</td><td>Handover record</td></tr>
<tr><td>settled_payslip_id</td><td>unsignedBigInteger nullable</td><td>The payslip that netted it off</td></tr>
<tr><td>settled_at</td><td>datetime nullable</td><td>When settlement happened</td></tr>
<tr><td>created_by, approved_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
</table>
<p>Keys: index(company_id, employee_id, year, month) · index(company_id, status)</p>
<p><strong>This is not employee_deductions.type = advance.</strong> That row is a loan: money lent, recovered as installments over several months. A salary_advances row is a part-payment of the employee own salary for that same month — no installments, no interest, settled inside that month payslip. Merging them makes both the loan report and the advance report wrong, which is why the tables are separate.</p>
<p>basis_gross is frozen because a mid-month salary revision must not retroactively change what a percentage-based advance was worth.</p>
<h3>API endpoints</h3>
<pre>GET   /api/v1/payroll/salary-advances?employee_id=&amp;month=&amp;year=&amp;status=
POST  /api/v1/payroll/salary-advances
GET   /api/v1/payroll/salary-advances/{id}
PUT   /api/v1/payroll/salary-advances/{id}
POST  /api/v1/payroll/salary-advances/{id}/submit
POST  /api/v1/payroll/salary-advances/{id}/record-payment
POST  /api/v1/payroll/salary-advances/{id}/cancel
GET   /api/v1/payroll/salary-advances/summary?employee_id=&amp;month=&amp;year=</pre>
<h3>Business rules</h3>
<ul>
<li>Every endpoint returns <strong>409</strong> when payroll_settings.advance_enabled = false. This is the company-wise switch.</li>
<li>amount is resolved <strong>server-side</strong> at creation and never accepted from the client: fixed gives requested_value, percentage gives round2(basis_gross * requested_value / 100). basis_gross and employee_salary_id are copied from the employee active salary as of the request date and are never recomputed afterwards.</li>
<li><strong>The ceiling is checked on the month total, not on the single request.</strong> Multiple advances in one month are allowed; their sum is what is capped. Both advance_max_percentage (as a share of basis_gross) and advance_max_amount must hold.</li>
<li>submit behaviour depends on policy: advance_requires_approval = false approves immediately and stamps approved_by; otherwise it submits through the platform ApprovalGateway with correlation id salary_advance:{id} and entity type salary_advance.</li>
<li>record-payment requires status approved. <strong>HR pays the money by hand — cash, cheque or a manual transfer — and this endpoint only records that it happened.</strong> Nothing is transmitted anywhere and there is no advance disbursement batch. It sets payment_channel, payment_reference, paid_at, paid_by and moves the row to paid.</li>
<li><strong>Only paid advances are netted off by the payslip generator.</strong> An approved-but-unpaid advance is ignored, because deducting money the employee never received would underpay them; it carries to whichever run first sees it as paid.</li>
<li>An advance whose period is already covered by a payroll run in status approved or later cannot be created, edited or cancelled — 409 naming the run. Editing settled money is not allowed.</li>
<li>Cancelling is permitted only from draft, pending_approval, approved. A paid advance cannot be cancelled: the money is gone, so it must settle or carry forward.</li>
<li>settled is set by the payroll run executor (task 8.3a-BE), never by this task endpoints.</li>
<li>The summary endpoint returns, for an employee and period, the month advance total, the remaining headroom under each ceiling and the resolved maximum a new request could ask for. The frontend uses it to prevent doomed requests.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function createAdvance(employee_id, month, year, method, value, reason):
    settings = PayrollSettings(company)
    if not settings.advance_enabled:                   throw Conflict("advances disabled")

    run = PayrollRun.find(company, month, year, "regular")
    if run and run.status in [APPROVED, PAID, LOCKED]: throw Conflict("period closed", run.id)

    salary = activeSalaryAsOf(employee_id, endOfMonth(month, year))
    if not salary:                                     throw Unprocessable("no active salary")

    basis  = salary.gross_salary
    amount = method == "fixed" ? value : round2(basis * value / 100)
    if amount &lt;= 0:                                    throw Unprocessable("amount must be positive")

    # ceiling is on the month total, not this request alone
    taken = sum(a.amount for a in SalaryAdvance.where(employee_id, month, year)
                          if a.status not in [REJECTED, CANCELLED])
    if taken + amount &gt; round2(basis * settings.advance_max_percentage / 100):
        throw Unprocessable("exceeds percentage ceiling", headroom)
    if settings.advance_max_amount and taken + amount &gt; settings.advance_max_amount:
        throw Unprocessable("exceeds amount ceiling", headroom)

    return SalaryAdvance.create({ employee_id, month, year,
                                  employee_salary_id: salary.id, basis_gross: basis,
                                  request_method: method, requested_value: value,
                                  amount, reason, status: DRAFT })

function submit(advance):
    if advance.status != DRAFT:                        throw Conflict()
    settings = PayrollSettings(company)

    if not settings.advance_requires_approval:
        return SalaryAdvanceExecutor.approve(advance, approved_by: currentUser)

    advance.status = PENDING_APPROVAL; advance.save()
    return approvalGateway.submit(
        moduleSlug: "payroll", actionSlug: "advance-approve",
        entityType: "salary_advance", entityId: advance.id,
        correlationId: "salary_advance:" + advance.id,
        onApproved: SalaryAdvanceExecutor.approve(advance))

function recordPayment(advance, channel, reference, paid_at):
    if advance.status != APPROVED:                     throw Conflict()
    if channel in ["cheque", "bank"] and not reference:
        throw Unprocessable("reference required for " + channel)

    advance.payment_channel   = channel
    advance.payment_reference = reference
    advance.paid_at           = paid_at or now()
    advance.paid_by           = currentUser
    advance.status            = PAID
    advance.save()
    emit SalaryAdvancePaid(advance)</pre>
<h3>Validation</h3>
<ul>
<li>employee_id, month, year, request_method, requested_value — required.</li>
<li>requested_value — greater than 0; at most 100 when request_method is percentage.</li>
<li>month 1 to 12; year within one year either side of the current year.</li>
<li>amount, basis_gross, employee_salary_id, status, settled_* — server-set, rejected if present in the request body.</li>
<li>payment_channel — required on record-payment; payment_reference required when it is cheque or bank.</li>
<li>paid_at — not in the future.</li>
<li>PUT may change requested_value, request_method and reason only, and only from draft; the amount is re-resolved and the ceiling re-checked.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>advance_enabled = false on any endpoint — 409</li>
<li>Month total would exceed either ceiling — 422, with the remaining headroom in the payload so the caller can retry with a valid figure</li>
<li>Employee has no active salary — 422</li>
<li>record-payment from a status other than approved — 409</li>
<li>Cheque or bank handover without a reference — 422</li>
<li>Cancelling a paid or settled advance — 409</li>
<li>Any write against a period whose run is approved or later — 409 naming the run id</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee on a 30,000 gross with a 50% advance produces a row with amount 15,000 and basis_gross 30,000.</li>
<li>A second request of 10% in the same month is rejected as over the 50% ceiling, and the error states the remaining headroom.</li>
<li>Raising the employee salary to 40,000 after the request leaves basis_gross and amount unchanged.</li>
<li>With advance_requires_approval = false, submit returns an already-approved advance and writes no approval_requests row.</li>
<li>With it true, the advance sits at pending_approval until the workflow completes, then the executor approves it exactly once.</li>
<li>record-payment with channel cash moves the row to paid; with channel cheque and no reference it returns 422.</li>
<li>An approved-but-unpaid advance does not appear on that month payslip; recording its payment and regenerating makes it appear.</li>
<li>Cancelling a paid advance returns 409.</li>
<li>Creating an advance for a month whose payroll run is already approved returns 409 naming the run.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for salary_advances.</li>
<li>SalaryAdvanceExecutor implemented and registered as salary_advance (stub created in task 4.1-BE).</li>
<li>Unit tests for the ceiling arithmetic covering: single request under, single request over, two requests whose sum is over, and the absolute-cap-null case.</li>
<li>Test proving basis_gross survives a later salary revision.</li>
<li>Both approval paths (policy bypass and workflow) covered.</li>
<li>SalaryAdvancePaid and SalaryAdvanceApproved events emitted with no listeners yet.</li>
<li>api collection/Payroll/Salary Advances/*.yml</li>
</ul>',
 16.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.5-FE Salary Advances — Frontend',
 'pay-7-5-fe-salary-advances',
 @vo + 50, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to raise an advance and see how much headroom an employee has left this month before I commit to a figure, so a request is never rejected after the employee has been told a number.</p>
<h3>Start after</h3><p>7.5-BE Salary Advances</p>
<h3>Permission</h3><p><code>payroll.advance-manage</code></p>
<h3>Menu</h3><p><strong>Payroll › Salary Advances</strong> — the whole item is hidden when <code>payroll_settings.advance_enabled = false</code></p>
<h3>Related tables</h3><ul><li>salary_advances, payroll_settings — see tasks 7.5-BE and 7.0-BE.</li></ul>
<h3>Frontend routes</h3>
<pre>/payroll/salary-advances
/payroll/salary-advances/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Advance list</strong> — employee, period, method, amount, status, payment channel and reference, with filters for employee, month/year and status. A period-total row shows what the company has advanced this month.</li>
<li><strong>Request drawer</strong> — employee picker, period, a fixed/percentage toggle, the value field and reason. As the toggle and value change, the drawer shows the resolved taka amount, the employee gross, the month advances so far and the <strong>remaining headroom</strong> under each ceiling, all from the summary endpoint.</li>
<li><strong>Record-payment dialog</strong> — channel (cash / cheque / bank / mobile banking), reference and payment date. The reference field becomes required for cheque and bank.</li>
<li><strong>Detail view</strong> — the request, its approval trail via the shared ApprovalStatusBadge, the handover record, and once settled a link to the payslip that netted it off.</li>
<li><strong>Payslip integration</strong> — the Advance already paid line between net pay and net payable on the payslip view (task 8.2-FE), linking back here.</li>
</ul>
<h3>API integration</h3>
<pre>GET   /api/v1/payroll/salary-advances?employee_id=&amp;month=&amp;year=&amp;status=
POST  /api/v1/payroll/salary-advances
GET   /api/v1/payroll/salary-advances/{id}
PUT   /api/v1/payroll/salary-advances/{id}
POST  /api/v1/payroll/salary-advances/{id}/submit
POST  /api/v1/payroll/salary-advances/{id}/record-payment
POST  /api/v1/payroll/salary-advances/{id}/cancel
GET   /api/v1/payroll/salary-advances/summary?employee_id=&amp;month=&amp;year=
GET   /api/v1/payroll/settings</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>New advance</strong></td><td><code>advance_enabled</code> <strong>and</strong> <code>payroll.advance-manage</code></td><td>—</td><td>Request drawer opens</td><td>Whole nav item is hidden when advances are disabled</td></tr>
<tr><td>2</td><td>Pick employee and period</td><td>in the drawer</td><td><code>GET /salary-advances/summary</code></td><td>Gross, month total so far, and <strong>remaining headroom</strong> under each ceiling</td><td>422 no active salary → inline, Save disabled</td></tr>
<tr><td>3</td><td>Toggle fixed / percentage</td><td>in the drawer</td><td>—</td><td>Resolved taka amount stays visible at all times</td><td>—</td></tr>
<tr><td>4</td><td><strong>Save draft</strong></td><td>amount ≤ headroom (capped in the field)</td><td><code>POST /salary-advances</code></td><td>Status <code>draft</code></td><td>422 over ceiling → field error carrying the headroom</td></tr>
<tr><td>5</td><td><strong>Submit</strong></td><td>status = <code>draft</code></td><td><code>POST /{id}/submit</code></td><td><code>pending_approval</code>, <strong>or</strong> straight to <code>approved</code> when the company policy bypasses approval</td><td>409 period closed → read-only with a link to the run</td></tr>
<tr><td>6</td><td><strong>Record payment</strong></td><td>status = <code>approved</code></td><td><code>POST /{id}/record-payment</code></td><td><code>paid</code>; dialog states it records a handover HR already made and moves no money</td><td>422 cheque or bank without a reference</td></tr>
<tr><td>7</td><td><strong>Cancel</strong></td><td>status ∈ <code>draft</code>, <code>pending_approval</code>, <code>approved</code></td><td><code>POST /{id}/cancel</code></td><td><code>cancelled</code></td><td>On a <code>paid</code> advance the control is shown <strong>disabled</strong> with a tooltip — the money is gone, it must settle or carry forward</td></tr>
<tr><td>8</td><td>Open a settled advance</td><td>status = <code>settled</code></td><td>—</td><td>Links to the payslip that netted it off</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li><strong>The nav item and the routes are hidden entirely when advance_enabled is false.</strong> The switch is read once from GET /payroll/settings; a user in a company that does not offer advances never sees the feature.</li>
<li>The headroom figure is the screen main value: the value field is capped at it, and exceeding it is blocked in the form rather than surfaced as a 422 after submit.</li>
<li>The fixed/percentage toggle keeps the resolved amount visible at all times, because HR and the employee usually agree on a taka figure while the policy is written in percent.</li>
<li>Status drives which actions render: draft shows submit and edit, approved shows record-payment, paid shows nothing but the settlement waiting state, settled is read-only with the payslip link.</li>
<li>The record-payment dialog states plainly that it records a handover HR has already made and does not move any money.</li>
<li>An advance for a closed period renders read-only with the reason and a link to the payroll run, instead of failing on save.</li>
<li>Amounts use the company currency formatter throughout; percent and taka are never shown in the same column without a unit.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>With advances disabled, no nav item, no route, and a direct URL redirects.</li>
<li>Typing 60 into the percentage field when the ceiling is 50 is prevented in the form, with the headroom shown.</li>
<li>The resolved taka amount is visible before the request is saved.</li>
<li>A cheque handover cannot be recorded without a reference.</li>
<li>A settled advance links to the payslip that netted it.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/salaryAdvanceApi.ts</code></li>
<li>Nav entry under Payroll, gated on both payroll.advance-manage and advance_enabled.</li>
<li>Currency formatting reuses the existing shared formatter; no local money helper.</li>
</ul>',
 10.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.6-BE Payment Mode Split — Backend',
 'pay-7-6-be-payment-modes',
 @vo + 51, @type_be,
 '<h3>Summary</h3><p>As HR, I want to say at salary-assignment time how much of an employee pay goes out as cash, as cheque and to the bank, so the disbursement run splits it that way every month without being told again.</p>
<h3>Start after</h3><p>0.1-BE Module Scaffolding</p>
<h3>Also needs (can be stubbed)</h3><p>7.1-BE Salary Structures</p>
<h3>Permission</h3><p><code>payroll.payment-mode-manage</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 7.6-FE</p>
<h3>Related tables</h3>
<ul>
<li><code>employee_salary_payment_modes</code> (new)</li>
<li><code>employee_salaries</code>, <code>employee_bank_accounts</code> — existing, Employee module</li>
<li><code>payslip_payment_allocations</code> — the frozen result, created in task 8.2b-BE</li>
</ul>
<h3>DB schema — employee_salary_payment_modes</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>employee_salary_id</td><td>unsignedBigInteger</td><td>employee_salaries.id — the split is versioned with the salary revision</td></tr>
<tr><td>channel</td><td>enum(cash, cheque, bank, mobile_banking)</td><td>Where this slice is paid</td></tr>
<tr><td>allocation_type</td><td>enum(fixed, percentage, residual)</td><td>How the slice is sized</td></tr>
<tr><td>value</td><td>decimal(12,2) nullable</td><td>Amount, or percent; <strong>null when residual</strong></td></tr>
<tr><td>employee_bank_account_id</td><td>unsignedBigInteger nullable</td><td>Required for bank and mobile_banking</td></tr>
<tr><td>display_order</td><td>int default 0</td><td>Order of application</td></tr>
<tr><td>created_by, updated_by</td><td>unsignedBigInteger nullable</td><td>Audit columns</td></tr>
</table>
<p>Keys: index(company_id, employee_salary_id) · unique(employee_salary_id, channel)</p>
<p>Attaching the split to employee_salaries rather than to the employee means a salary revision carries its own split, and a payslip from six months ago stays explainable.</p>
<h3>API endpoints</h3>
<pre>GET /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes
PUT /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes</pre>
<p>There is no per-row create, update or delete endpoint. The rows are only valid as a <strong>set</strong> — exactly one residual, bounded totals — so the PUT replaces the whole set in one transaction.</p>
<h3>Business rules</h3>
<ul>
<li><strong>Exactly one residual row is mandatory.</strong> That row is what guarantees the split totals the net payable exactly, whatever the deductions, the advance and the rounding turn out to be. A pure fixed/percentage configuration can never do that against a net that changes every month, so a set without a residual row is rejected.</li>
<li>value is required and greater than 0 for fixed and percentage, and must be null for residual.</li>
<li>Percentages are in the range above 0 and up to 100. They are <strong>applied to the payslip net payable, not to gross</strong> (see task 8.2b-BE); configuration is entered against gross only because that is the figure HR knows.</li>
<li>employee_bank_account_id is required for bank and mobile_banking, must belong to the same employee, and must be active.</li>
<li>At most one row per channel, enforced by the unique key — an employee does not get two separate cash lines.</li>
<li>Fixed plus percentage rows totalling more than the salary gross_salary returns a <strong>soft warning</strong> in the response rather than a block, since the real net is lower anyway and the allocator caps each line at what remains.</li>
<li><strong>An empty set is valid</strong> and means everything to the primary bank account. This is the pre-existing behaviour, so employees configured before this task keep working untouched.</li>
<li>Editing the set does not touch already-generated payslips; their allocations were frozen at generation (task 8.2b-BE).</li>
<li>display_order decides the order slices are taken; the residual row is always applied last regardless of its order value.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>modes — array, may be empty; each entry requires channel and allocation_type.</li>
<li>Exactly one entry with allocation_type = residual when the array is non-empty, otherwise 422.</li>
<li>value — required and greater than 0 for fixed and percentage; must be absent or null for residual.</li>
<li>value at most 100 for percentage.</li>
<li>employee_bank_account_id — required for bank and mobile_banking, must exist, must belong to the employee, must be active.</li>
<li>Duplicate channel within the payload — 422, checked before the unique key fires.</li>
<li>The target employee_salaries row must belong to the caller company and must not be superseded by a newer active revision.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Zero or two or more residual rows — 422 naming the rule</li>
<li>Bank channel with no account, or an account belonging to another employee — 422</li>
<li>Duplicate channel in the payload — 422</li>
<li>Editing the modes of a salary revision that has already produced a finalized payslip — allowed, 200 plus a warning stating that existing payslips are unaffected and the change applies from the next generation</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Saving cash 5,000 fixed, cheque 10%, bank residual on a 30,000 salary succeeds and reads back in display_order.</li>
<li>Saving the same set with two residual rows returns 422.</li>
<li>Saving a bank row without an account returns 422.</li>
<li>Saving a bank row with another employee account returns 422.</li>
<li>Saving an empty set succeeds and the employee falls back to their primary account at allocation time.</li>
<li>A second PUT fully replaces the earlier set, leaving no orphan rows.</li>
<li>Assigning a new salary to the employee leaves the previous revision modes intact and untouched.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for employee_salary_payment_modes.</li>
<li>The set-level validation lives in one Form Request rule object, reused by task 8.2b-BE pre-generation assertion.</li>
<li>Transactional replace tested for orphans.</li>
<li>api collection/Payroll/Payment Modes/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '7.6-FE Payment Mode Split — Frontend',
 'pay-7-6-fe-payment-modes',
 @vo + 52, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to set the cash / cheque / bank split in the same screen where I set the salary, so I never assign a salary and forget to say how it gets paid.</p>
<h3>Start after</h3><p>7.6-BE Payment Mode Split</p>
<h3>Permission</h3><p><code>payroll.payment-mode-manage</code></p>
<h3>Menu</h3><p><strong>Employees › Salary tab</strong> — a Payroll-owned panel hosted by the Employee module; <strong>no Payroll nav item</strong></p>
<h3>Related tables</h3><ul><li>employee_salary_payment_modes — see task 7.6-BE.</li></ul>
<h3>Frontend routes</h3>
<pre>No new route. The panel mounts inside the existing Employee salary assignment form:
/employee/employees/{id}  →  Salary tab  →  Payment split panel</pre>
<p><strong>Ownership:</strong> the component, its API client and its validation live in modules/payroll and are <strong>imported</strong> by the Employee salary form. Payroll owns payout logic; the Employee module hosts the surface and learns nothing about channels. This mirrors the ownership split already used for salary structures in task 7.1.</p>
<h3>Main screen sections</h3>
<ul>
<li><strong>Payment split panel</strong> — a repeatable row builder: channel, allocation type, value, bank account. Rows reorder by drag.</li>
<li><strong>Residual marker</strong> — the residual row renders visually distinct and pinned last, labelled everything remaining, with its value field disabled.</li>
<li><strong>Live preview</strong> — a sample net payable, defaulting to the salary gross and editable, run through the same allocation rules as task 8.2b-BE, showing the taka each channel would receive and the total.</li>
<li><strong>Empty state</strong> — All pay goes to the primary bank account, with an Add split button.</li>
</ul>
<h3>API integration</h3>
<pre>GET /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes
PUT /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes
GET /api/v1/employee/employees/{employeeId}/bank-accounts</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Add split</strong></td><td><code>payroll.payment-mode-manage</code>, empty state</td><td>—</td><td>First row <strong>plus an auto-created residual row</strong>, so a valid set is the default</td><td>—</td></tr>
<tr><td>2</td><td>Pick channel</td><td>per row</td><td><code>GET /employee/employees/{id}/bank-accounts</code></td><td>Bank account picker appears only for bank / mobile banking, listing that employee’s active accounts</td><td>—</td></tr>
<tr><td>3</td><td>Enter value</td><td>fixed or percentage rows</td><td>—</td><td>Live preview recomputes; a fixed line visibly <strong>capped</strong> when the sample net is lowered below it</td><td>—</td></tr>
<tr><td>4</td><td>Drag to reorder</td><td>non-residual rows</td><td>—</td><td>Preview order changes; residual always applies last</td><td>—</td></tr>
<tr><td>5</td><td>Delete last non-residual row</td><td>—</td><td>—</td><td>Whole set clears back to the empty state</td><td>Residual row offers no delete</td></tr>
<tr><td>6</td><td><strong>Save</strong></td><td><code>payment-mode-manage</code></td><td><code>PUT /employee-salaries/{id}/payment-modes</code></td><td>Set replaced in one transaction</td><td>422 two residual rows, duplicate channel, or bank row without an account</td></tr>
<tr><td>7</td><td>Save with fixed + % over gross</td><td>—</td><td>same</td><td>Saves, with an <strong>inline non-blocking warning</strong> — the real net is lower anyway</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>Adding the first split row auto-creates a residual row, so a valid configuration is the default and the 422 is never reached by ordinary use.</li>
<li>Deleting the residual row is not offered; deleting the last non-residual row clears the whole set back to the empty state.</li>
<li>The bank-account picker appears only for bank and mobile_banking, and lists only that employee active accounts, with the primary marked.</li>
<li>The percentage field shows a percent adornment and the fixed field shows the currency symbol — the two are never visually interchangeable.</li>
<li>The preview is the panel main value: HR sees cash 5,000 / cheque 1,150 / bank 5,350 before saving, and sees the fixed line get capped when the sample net is lowered below it.</li>
<li>A soft warning from the API (fixed plus percentage over gross) renders inline and does not block saving.</li>
<li>The panel is read-only, with an explanatory note, for users holding the Employee salary permission but not payroll.payment-mode-manage.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR sets the split without leaving the salary form.</li>
<li>The preview totals exactly the sample net payable in every configuration, including when a fixed line is capped.</li>
<li>A residual row always exists once any split row does, and cannot be deleted or valued.</li>
<li>The bank account picker never offers another employee account.</li>
<li>Without the payment-mode permission the panel renders but does not save.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/paymentModeApi.ts</code> and <code>modules/payroll/components/PaymentSplitPanel.tsx</code></li>
<li>The panel imported by the Employee salary form with no Payroll-specific logic leaking into the Employee module.</li>
<li>The preview reuses one allocation helper shared with task 8.2-FE, not a second implementation of the rules.</li>
</ul>',
 6.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now);

-- =====================================================================
--  PART H — PAYROLL EXECUTION  (11 tasks, 59 points)
--  8.0 is deliberately built AFTER 8.1: attendance_snapshots carries a
--  payroll_run_id and cannot exist before payroll runs do.
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_payroll,
 '8.1-BE Payroll Run Creation — Backend',
 'pay-8-1-be-payroll-run-creation',
 @vo + 53, @type_be,
 '<h3>Summary</h3><p>As Finance, I want to create a monthly payroll run that pulls in every eligible employee, so payslips can be generated as one batch.</p>
<h3>Start after</h3><p>6.2-BE Monthly Freeze</p>
<h3>Also needs (can be stubbed)</h3><p>7.0-BE Payroll Settings · 7.1-BE Salary Structures</p>
<h3>Permission</h3><p><code>payroll.run-create</code>, <code>payroll.run-override-readiness</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 8.1-FE</p>
<h3>Related tables</h3><ul><li><code>payroll_runs</code> (new)</li></ul>
<h3>DB schema — payroll_runs</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>month, year</td><td>tinyint, smallint</td><td>Payroll period</td></tr>
<tr><td>run_type</td><td>enum(regular, off_cycle) default regular</td><td>Allows a correction run alongside the regular one</td></tr>
<tr><td>status</td><td>enum(draft, processing, pending_approval, approved, paid, locked, failed)</td><td>Lifecycle</td></tr>
<tr><td>total_employees</td><td>int default 0</td><td>Employees included</td></tr>
<tr><td>total_amount</td><td>decimal(15,2) default 0</td><td>Sum of net pay</td></tr>
<tr><td>processed_at</td><td>datetime nullable</td><td>When generation completed</td></tr>
<tr><td>approved_by</td><td>unsignedBigInteger nullable</td><td>Approver</td></tr>
<tr><td>created_by</td><td>unsignedBigInteger nullable</td><td>Creator</td></tr>
</table>
<p>Keys: unique(company_id, month, year, run_type) · index(company_id, status)</p>
<h3>API endpoints</h3>
<pre>GET  /api/v1/payroll/payroll-runs?month=&amp;year=&amp;status=
POST /api/v1/payroll/payroll-runs
GET  /api/v1/payroll/payroll-runs/{id}
GET  /api/v1/payroll/payroll-runs/{id}/readiness</pre>
<p>readiness reports which employees are and are not ready, without creating anything.</p>
<h3>Business rules</h3>
<ul>
<li>A run is created for a month and year, including only employees whose monthly attendance is ready_for_payroll (task 6.1-BE).</li>
<li>If any active employee is not ready, creation fails and the response lists them. payroll.run-override-readiness allows creation anyway, excluding the unready employees, and the override is audit-logged.</li>
<li>One run per company per month, year and run type.</li>
<li>Status transitions are enforced: draft to processing to pending_approval to approved to paid to locked, with failed reachable only from processing. Any other transition is rejected.</li>
<li>total_employees and total_amount are recomputed as payslips are generated (task 8.2a-BE).</li>
<li>A run cannot move to paid without having been approved.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function createPayrollRun(company_id, month, year, run_type, override):
    if PayrollRun.exists(company_id, month, year, run_type):
        raise Error("A payroll run already exists for this period")

    employees = activeEmployees(company_id, asOf: endOf(month, year))
    ready = [], not_ready = []

    for e in employees:
        approval = MonthlyAttendanceApproval.find(e.id, month, year)
        (approval and approval.ready_for_payroll) ? ready.push(e) : not_ready.push(e)

    if not_ready and not override:
        raise Error(count(not_ready) + " employees are not ready for payroll", not_ready)

    run = PayrollRun.create({ company_id, month, year, run_type,
                              status: DRAFT, total_employees: count(ready) })
    if not_ready and override:
        auditLog("payroll.run.readiness_override", run, not_ready)
    return run</pre>
<h3>Validation</h3>
<ul>
<li>month 1 to 12; year within plus or minus 5 of the current year.</li>
<li>run_type within the enum.</li>
<li>override honoured only with payroll.run-override-readiness.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Duplicate run for the period — 409</li>
<li>Unready employees without override — 422, body lists employee ids and the blocking reason for each</li>
<li>Invalid status transition — 409 naming the current and attempted status</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can create a run once every included employee month is approved and frozen.</li>
<li>Creating a second regular run for the same period is blocked; an off-cycle run for the same period is allowed.</li>
<li>The readiness endpoint lists unready employees with reasons before anything is created.</li>
<li>Overriding readiness excludes the unready employees and records an audit entry.</li>
<li>The run employee count and total update as payslips are generated.</li>
<li>A run cannot be marked paid without prior approval.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Status machine implemented as an explicit transition map and unit-tested for every illegal transition.</li>
<li>Readiness check shares one code path with creation.</li>
<li>api collection/Payroll/Payroll Runs/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.1-FE Payroll Run Creation — Frontend',
 'pay-8-1-fe-payroll-run-creation',
 @vo + 54, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want to see whether a month is ready before I create its run, and to understand exactly who is blocking it.</p>
<h3>Start after</h3><p>8.1-BE Payroll Run Creation</p>
<h3>Also needs (can be stubbed)</h3><p>6.2-FE Monthly Freeze</p>
<h3>Permission</h3><p><code>payroll.run-create</code>, <code>payroll.run-override-readiness</code></p>
<h3>Menu</h3><p><strong>Payroll › Payroll Runs</strong></p>
<h3>Related tables</h3><ul><li>payroll_runs — see task 8.1-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/payroll-runs
/payroll/payroll-runs/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Run list</strong> — period, run type, status, employee count, total amount.</li>
<li><strong>Create run flow</strong> — period picker, then a readiness report listing ready and unready employees before the confirm step.</li>
<li><strong>Run detail</strong> — status timeline, included employees, and links into payslips and disbursement.</li>
</ul>
<h3>API integration</h3>
<pre>GET  /api/v1/payroll/payroll-runs
POST /api/v1/payroll/payroll-runs
GET  /api/v1/payroll/payroll-runs/{id}
GET  /api/v1/payroll/payroll-runs/{id}/readiness</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Pick a period</td><td><code>payroll.run-create</code></td><td><code>GET /payroll-runs/{id}/readiness</code></td><td>Ready and unready employees listed <strong>before</strong> Create becomes available</td><td>—</td></tr>
<tr><td>2</td><td>Unready row → <strong>Fix</strong></td><td>always</td><td>—</td><td>Navigates to that employee’s monthly approval row</td><td>—</td></tr>
<tr><td>3</td><td><strong>Create run</strong></td><td>every employee ready</td><td><code>POST /payroll-runs</code></td><td>Run created in <code>draft</code></td><td>409 duplicate run for the period</td></tr>
<tr><td>4</td><td><strong>Create with override</strong></td><td><code>run-override-readiness</code></td><td>same, <code>override: true</code></td><td>Unready employees excluded; audit entry written</td><td>Option is off by default and states how many will be excluded</td></tr>
<tr><td>5</td><td>Open run detail</td><td>always</td><td><code>GET /payroll-runs/{id}</code></td><td>Status timeline with the current state highlighted</td><td>—</td></tr>
</table>
<p><strong>Deliberately ordered:</strong> readiness is fetched and shown <em>before</em> Create is offered. Creating first and reading the failure afterwards is the wrong order for a batch action.</p>
<h3>UI rules</h3>
<ul>
<li>Readiness is checked and shown <strong>before</strong> the create button becomes available — creating and then reading a failure is the wrong order for a batch action.</li>
<li>Each unready employee links to their monthly approval row, so the blocker can be cleared in one hop.</li>
<li>The override option is visible only to holders of the permission, is off by default, and states how many employees will be excluded.</li>
<li>The status timeline shows the full lifecycle with the current state highlighted, so nobody has to guess what comes next.</li>
<li>Off-cycle runs are visually distinguished from regular runs in the list.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance sees the readiness report before committing to a run.</li>
<li>Every unready employee is one click from the screen that fixes them.</li>
<li>The override, when used, states its consequence up front.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/payrollRunApi.ts</code></li>
<li>Reuses MonthStateBadge from task 6.2-FE; nav entry under Payroll.</li>
</ul>',
 6.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.0-BE Attendance Snapshot for Payroll — Backend',
 'pay-8-0-be-attendance-snapshot',
 @vo + 55, @type_be,
 '<h3>Summary</h3><p>As the system, I want an immutable snapshot of each employee attendance captured when a payroll run is built, so payroll stays reproducible even if attendance is corrected afterwards.</p>
<h3>Start after</h3><p>8.1-BE Payroll Run Creation · 6.2-BE Monthly Freeze</p>
<h3>Permission</h3><p><code>payroll.run-create</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 8.0-FE</p>
<h3>Related tables</h3>
<ul><li><code>attendance_snapshots</code> (new)</li><li>monthly_attendance_approvals, payroll_runs (existing)</li></ul>
<h3>DB schema — attendance_snapshots</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>payroll_run_id</td><td>unsignedBigInteger</td><td>Which run this snapshot feeds</td></tr>
<tr><td>month, year</td><td>tinyint, smallint</td><td>Period covered</td></tr>
<tr><td>present_days</td><td>decimal(7,2)</td><td>Copied from the frozen monthly summary</td></tr>
<tr><td>absent_days</td><td>decimal(7,2)</td><td>Copied</td></tr>
<tr><td>leave_days</td><td>decimal(7,2)</td><td>Copied</td></tr>
<tr><td>unpaid_leave_days</td><td>decimal(7,2)</td><td>Copied — drives pro-rating</td></tr>
<tr><td>half_days</td><td>decimal(7,2)</td><td>Copied</td></tr>
<tr><td>late_count</td><td>int</td><td>Copied</td></tr>
<tr><td>overtime_hours</td><td>decimal(7,2)</td><td>Copied</td></tr>
<tr><td>working_hours</td><td>decimal(7,2)</td><td>Copied</td></tr>
<tr><td>source_monthly_approval_id</td><td>unsignedBigInteger</td><td>The frozen monthly_attendance_approvals row</td></tr>
<tr><td>snapshot_taken_at</td><td>timestamp</td><td>When this snapshot was built</td></tr>
</table>
<p>Keys: unique(payroll_run_id, employee_id) · index(company_id, month, year)</p>
<p><strong>Immutable.</strong> No updated_at, no update endpoint, no delete endpoint.</p>
<h3>API endpoints</h3>
<pre>POST /api/v1/payroll/payroll-runs/{id}/build-snapshots
GET  /api/v1/payroll/attendance-snapshots?payroll_run_id=&amp;employee_id=
GET  /api/v1/payroll/attendance-snapshots/{id}
GET  /api/v1/payroll/attendance-snapshots/{id}/divergence</pre>
<p>divergence compares the snapshot against current live attendance and reports differences — diagnostic only, it changes nothing.</p>
<h3>Business rules</h3>
<ul>
<li>One snapshot per employee per payroll run, copied from the <strong>frozen</strong> monthly_attendance_approvals row.</li>
<li>A snapshot cannot be built from a month that is not frozen (task 6.2-BE).</li>
<li>Snapshots are never updated in place. A reprocessed or off-cycle run creates a new, separate set of snapshots.</li>
<li><strong>The payslip generator reads only from attendance_snapshots</strong> — never from live attendance_records or monthly_attendance_approvals. This is the property that makes an old payslip explainable months later.</li>
<li>Building is idempotent per run: re-running skips employees who already have a snapshot rather than duplicating or overwriting.</li>
</ul>
<h3>Validation</h3>
<ul>
<li>Target run must be in draft status.</li>
<li>Every included employee must have a frozen monthly approval for the run period.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Building from a month that is not frozen — 409 listing the employees whose months are unfrozen</li>
<li>Missing source monthly approval — 404 naming the employee</li>
<li>Building on a run past draft — 409</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Snapshot values exactly match the frozen monthly summary at the moment of freeze.</li>
<li>Correcting attendance after freeze leaves every existing snapshot unchanged.</li>
<li>A new off-cycle run produces a new snapshot set rather than mutating the old one.</li>
<li>Re-running the build on the same run creates no duplicates.</li>
<li>No code path in payslip generation reads attendance_records or monthly_attendance_approvals — asserted by a test that generates a payslip with those tables mutated after freeze and gets identical output.</li>
<li>The divergence endpoint reports a difference after a post-freeze correction, without altering the snapshot.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration with the unique key.</li>
<li>A test that mutates live attendance after snapshotting and asserts payslip output is unchanged.</li>
<li>Review check that the payroll code path contains no query against attendance_records.</li>
<li>api collection/Payroll/Attendance Snapshots/*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s9, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.0-FE Attendance Snapshot for Payroll — Frontend',
 'pay-8-0-fe-attendance-snapshot',
 @vo + 56, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want to see exactly what attendance data a payslip was generated from, even months later, so payroll figures are always explainable.</p>
<h3>Start after</h3><p>8.0-BE Attendance Snapshot</p>
<h3>Also needs (can be stubbed)</h3><p>8.1-FE Payroll Run Creation</p>
<h3>Permission</h3><p><code>payroll.run-create</code>, <code>payroll.payslip-view-all</code></p>
<h3>Menu</h3><p><strong>Payroll › Payroll Runs</strong> — embedded on the run detail (8.1-FE), no own nav item</p>
<h3>Related tables</h3><ul><li>attendance_snapshots — see task 8.0-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/payroll-runs/{id}/snapshots
/payroll/payroll-runs/{id}/snapshots/{employeeId}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Snapshot list</strong> — per run: employee, key attendance totals, snapshot timestamp.</li>
<li><strong>Snapshot detail</strong> — every captured field, read-only.</li>
<li><strong>Divergence indicator</strong> — flags rows where live attendance has since changed, with a side-by-side comparison on the detail view.</li>
<li><strong>Build snapshots action</strong> — on a draft run, with a pre-flight list of employees whose months are not yet frozen.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/payroll/payroll-runs/{id}/build-snapshots
GET  /api/v1/payroll/attendance-snapshots?payroll_run_id=
GET  /api/v1/payroll/attendance-snapshots/{id}
GET  /api/v1/payroll/attendance-snapshots/{id}/divergence</pre>
<h3>UI rules</h3>
<ul>
<li>No edit control exists anywhere on these screens — the read-only nature is the feature, and offering a disabled edit button would misrepresent it.</li>
<li>The divergence indicator is explicitly labelled as diagnostic, with a note that the payslip used the snapshot values.</li>
<li>The build action is unavailable once the run leaves draft, with the reason stated.</li>
<li>The pre-flight list links each unfrozen month to the freeze screen.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can open any past run and see precisely what attendance produced each payslip.</li>
<li>A divergence between snapshot and live attendance is visible and clearly marked as informational.</li>
<li>No control on these screens can modify a snapshot.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/attendanceSnapshotApi.ts</code></li>
<li>Linked from the payroll run detail view.</li>
</ul>',
 4.00, 'todo', 'medium', @s9, NULL, NULL, @now, @now);

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_payroll,
 '8.2a-BE Payslip Generation — Backend',
 'pay-8-2a-be-payslip-generation',
 @vo + 57, @type_be,
 '<h3>Summary</h3><p>As Finance, I want a payslip generated per employee in a run, with gross, deductions and net calculated from frozen data and a stored per-component breakdown.</p>
<p><strong>Split note.</strong> Split from the original 13-point 8.2-BE. This task owns the payslips table and the earnings to deductions to tax to loans to net pipeline. <strong>8.2b-BE</strong> adds salary-advance settlement and the cash/cheque/bank split.</p>
<p><strong>Hard ordering constraint.</strong> This task writes advance_paid = 0 and net_payable = net_pay as a provisional value. That is correct only while no advances have been recorded. <strong>Task 8.2b-BE must be merged before task 8.3b-BE (disbursement)</strong>, or a company using advances would be paid the full net twice.</p>
<h3>Start after</h3><p>8.0-BE Attendance Snapshot</p>
<h3>Also needs (can be stubbed)</h3><p>7.0-BE Payroll Settings · 7.2-BE Structure Components · 7.3-BE Tax Slabs · 7.4-BE Deductions &amp; Loans</p>
<h3>Permission</h3><p><code>payroll.run-create</code>, <code>payroll.payslip-view-own</code> / <code>-all</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 8.2-FE</p>
<h3>Related tables</h3><ul><li><code>payslips</code> (new)</li></ul>
<h3>DB schema — payslips</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>payroll_run_id</td><td>unsignedBigInteger</td><td>Which run this belongs to</td></tr>
<tr><td>attendance_snapshot_id</td><td>unsignedBigInteger</td><td><strong>attendance_snapshots.id</strong> — the frozen data used</td></tr>
<tr><td>employee_salary_id</td><td>unsignedBigInteger</td><td>Which employee_salaries row was used</td></tr>
<tr><td>gross_earnings</td><td>decimal(12,2)</td><td>Sum of earning components</td></tr>
<tr><td>total_deductions</td><td>decimal(12,2)</td><td>Sum of deduction components</td></tr>
<tr><td>net_pay</td><td>decimal(12,2)</td><td>gross minus deductions, floored at 0 — the employee <strong>earned</strong> net</td></tr>
<tr><td>advance_paid</td><td>decimal(12,2) default 0</td><td>Written by task 8.2b-BE; <strong>always 0 from this task</strong></td></tr>
<tr><td>net_payable</td><td>decimal(12,2)</td><td>Written by task 8.2b-BE; <strong>equals net_pay from this task</strong></td></tr>
<tr><td>advance_carry_forward</td><td>decimal(12,2) default 0</td><td>Written by task 8.2b-BE; always 0 from this task</td></tr>
<tr><td>earnings_breakdown</td><td>json</td><td>Per-component amounts, keyed by component_code</td></tr>
<tr><td>deductions_breakdown</td><td>json</td><td>Per-component amounts</td></tr>
<tr><td>currency_code</td><td>char(3)</td><td>From the employee salary row</td></tr>
<tr><td>status</td><td>enum(draft, finalized, paid)</td><td>Lifecycle</td></tr>
<tr><td>needs_review</td><td>boolean default false</td><td>Net pay would have gone negative, or an installment was skipped</td></tr>
<tr><td>generated_at</td><td>timestamp</td><td>When generated</td></tr>
</table>
<p>Keys: unique(payroll_run_id, employee_id) · index(company_id, employee_id)</p>
<p>The three advance columns ship in <strong>this</strong> task migration even though only 8.2b-BE populates them, so 8.2b needs no schema change and the two tasks cannot deadlock on migration order.</p>
<p>The original document described attendance_snapshot_id as referencing monthly_attendance_approvals.id while also mandating snapshot-only reads. It references <strong>attendance_snapshots.id</strong>.</p>
<p>net_pay keeps its original meaning — the <strong>earned</strong> net for the month. Tax certificates, salary reports and year-end statements read net_pay, never net_payable.</p>
<h3>API endpoints</h3>
<pre>POST /api/v1/payroll/payroll-runs/{id}/generate-payslips
GET  /api/v1/payroll/payroll-runs/{id}/generation-status
GET  /api/v1/payroll/payroll-runs/{id}/payslips
GET  /api/v1/payroll/payslips/{id}
GET  /api/v1/payroll/payslips/me?month=&amp;year=
POST /api/v1/payroll/payslips/{id}/regenerate
GET  /api/v1/payroll/payslips/{id}/download</pre>
<h3>Business rules</h3>
<ul>
<li>Generation is <strong>queued and chunked</strong> at payroll.payslip_generation_chunk_size (default 100). A 500-employee run is not one HTTP request. generation-status reports progress and per-employee failures.</li>
<li>Generation reads attendance <strong>only</strong> from attendance_snapshots.</li>
<li>The active salary is the employee_salaries row with the latest effective_date not after the run period end and status Active. Its id is stored on the payslip.</li>
<li>When employee_salaries.basic_salary is set, it is used as-is for the is_basic component instead of the structure calculated value.</li>
<li>Components with prorated = false keep their full value regardless of unpaid days.</li>
<li>Overtime is paid only when employee_salaries.overtime_eligible = true, using the shared hourlyRate() from task 7.0-BE.</li>
<li>Income tax is skipped entirely when employee_tax_profiles.tax_exemption = 1 <strong>or</strong> employee_salaries.tax_applicable = 0.</li>
<li>The taxable base is the sum of earning components with is_taxable = true, annualised by twelve — not the sum of all earnings.</li>
<li>Recurring deductions are applied through the idempotent path from task 7.4-BE.</li>
<li>Negative net pay is floored at zero and the payslip is flagged needs_review rather than silently zeroed.</li>
<li>Only draft payslips can be regenerated; regeneration first reverses that run deduction entries. A payslip is locked once its run is approved.</li>
<li>Employees may read their own payslips with payroll.payslip-view-own.</li>
<li><strong>The generator ends with a single extension point</strong>, PayslipFinaliser, shipped here as a pass-through that sets advance_paid = 0 and net_payable = net_pay. Task 8.2b-BE replaces its body. This is what keeps the two tasks from touching the same code twice.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function generatePayslip(run, employee_id):
    snapshot  = AttendanceSnapshot.for(run.id, employee_id)      # required; frozen
    salary    = activeSalaryAsOf(employee_id, run.period_end)
    structure = SalaryStructure(salary.salary_structure_id)
    settings  = PayrollSettings(run.company_id)

    working_days = workingDaysInMonth(employee_id, run.month, run.year)
    unpaid_days  = snapshot.absent_days + snapshot.unpaid_leave_days
    pro_rate     = working_days &gt; 0 ? (working_days - unpaid_days) / working_days : 0

    # ---- earnings ----
    earnings = {}
    for c in components(structure, "earning") order by display_order:
        raw = (c.is_basic and salary.basic_salary is not null)
              ? salary.basic_salary
              : (c.calculation_type == "fixed"
                    ? c.value
                    : baseFor(c.percentage_base, salary) * c.value / 100)
        earnings[c.component_code] = round2(c.prorated ? raw * pro_rate : raw)

    if salary.overtime_eligible:
        earnings["overtime"] = round2(snapshot.overtime_hours
                                      * hourlyRate(salary, settings, working_days)
                                      * settings.overtime_multiplier)

    gross = sum(earnings)

    # ---- deductions ----
    deductions = {}
    for c in components(structure, "deduction") order by display_order:
        deductions[c.component_code] = round2(
            c.calculation_type == "fixed" ? c.value : gross * c.value / 100)

    tax_exempt = taxProfile(employee_id)?.tax_exemption or not salary.tax_applicable
    if not tax_exempt:
        taxable_monthly = sum(earnings[c] for c where c.is_taxable)
        slab = TaxSlab.active(run.company_id, run.year)          # required
        deductions["income_tax"] = round2(
            calculateSlabTax(taxable_monthly * 12, slab.slabs) / 12)

    for d in activeDeductions(employee_id, run.month, run.year):
        amt = applyInstallment(d, run, projected_net: gross - sum(deductions))  # task 7.4
        if amt &gt; 0: deductions[d.type] = amt

    total_deductions = sum(deductions)
    net_pay          = gross - total_deductions
    needs_review     = false

    if net_pay &lt; 0:
        net_pay = 0; needs_review = true

    net_pay = roundTo(net_pay, settings.round_net_pay_to)

    payslip = upsert Payslip{ run.id, employee_id, attendance_snapshot_id: snapshot.id,
                    employee_salary_id: salary.id, gross_earnings: gross,
                    total_deductions, net_pay,
                    earnings_breakdown: earnings, deductions_breakdown: deductions,
                    currency_code: salary.currency_code, status: draft, needs_review }

    PayslipFinaliser.finalise(payslip, salary)   # pass-through here; real body in 8.2b-BE

class PayslipFinaliser:              # 8.2a-BE implementation - replaced by 8.2b-BE
    function finalise(payslip, salary):
        payslip.advance_paid          = 0
        payslip.net_payable           = payslip.net_pay
        payslip.advance_carry_forward = 0
        payslip.save()</pre>
<h3>Validation</h3>
<ul>
<li>The run must be in draft or processing.</li>
<li>Every included employee must have a snapshot, an active salary and an active structure; a missing one fails that employee only and is reported, not the whole run.</li>
<li>An active tax slab set for the run year is required unless every employee is tax-exempt.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>No snapshot for an employee — that employee fails with a reason; the run continues</li>
<li>No active tax slab set for the year with taxable employees present — 422 before generation starts</li>
<li>Regenerating a finalized or paid payslip — 409</li>
<li>Reading another employee payslip with only payslip-view-own — 403</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Generating a run produces one payslip per eligible employee with correct gross, deductions and net.</li>
<li>An employee with unpaid leave shows a proportionally reduced gross, while their non-prorated allowance is unchanged.</li>
<li>An employee with overtime_eligible = false receives no overtime line, even with overtime hours in the snapshot.</li>
<li>A tax-exempt employee has no income_tax line at any income level.</li>
<li>The taxable base excludes non-taxable components, verified against a structure containing one of each.</li>
<li>Each breakdown sums exactly to gross_earnings and total_deductions.</li>
<li>Regenerating a draft three times leaves the loan balance decremented exactly once.</li>
<li>A negative-net payslip is written with net_pay 0 and needs_review true.</li>
<li>Generation of 500 employees completes through the queue with progress reported.</li>
<li>Every generated payslip has net_payable equal to net_pay and advance_paid 0 — the documented provisional state until task 8.2b-BE lands.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for payslips, <strong>including</strong> the three advance columns.</li>
<li>Every rule above covered by a unit test with an explicit worked example.</li>
<li>One integration test generating a full run against fixture data, asserting the totals by hand.</li>
<li>Chunked queued job with a progress endpoint and per-employee failure reporting.</li>
<li>PayslipFinaliser extracted as an injected service with a pass-through implementation and a test asserting the provisional values.</li>
<li>PDF rendering for download.</li>
<li>api collection/Payroll/Payslips/*.yml</li>
</ul>',
 16.00, 'todo', 'medium', @s9, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.2b-BE Advance Settlement & Payment Allocation — Backend',
 'pay-8-2b-be-advance-allocation',
 @vo + 58, @type_be,
 '<h3>Summary</h3><p>As Finance, I want any salary advance already handed over netted off the payslip, and the remainder split across the employee cash, cheque and bank channels and frozen, so disbursement knows exactly where each taka goes.</p>
<p><strong>Split note.</strong> Split from the original 13-point 8.2-BE. Replaces the pass-through PayslipFinaliser from task 8.2a-BE with the real body. <strong>Must merge before task 8.3b-BE.</strong></p>
<h3>Start after</h3><p>8.2a-BE Payslip Generation</p>
<h3>Also needs (can be stubbed)</h3><p>7.5-BE Salary Advances · 7.6-BE Payment Mode Split</p>
<h3>Permission</h3><p><code>payroll.run-create</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 8.2-FE as the settlement block and the channel split panel</p>
<h3>Related tables</h3>
<ul>
<li><code>payslip_payment_allocations</code> (new)</li>
<li><code>payslips</code> — columns already exist from task 8.2a-BE; this task populates advance_paid, net_payable, advance_carry_forward</li>
<li><code>salary_advances</code> — read for settlement, see task 7.5-BE</li>
<li><code>employee_salary_payment_modes</code> — read for the split, see task 7.6-BE</li>
</ul>
<h3>DB schema — payslip_payment_allocations</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>payslip_id</td><td>unsignedBigInteger</td><td>Parent payslip</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>channel</td><td>enum(cash, cheque, bank, mobile_banking)</td><td>Where this slice goes</td></tr>
<tr><td>amount</td><td>decimal(12,2)</td><td>Slice amount</td></tr>
<tr><td>employee_bank_account_id</td><td>unsignedBigInteger nullable</td><td>Null for cash and cheque</td></tr>
<tr><td>source</td><td>enum(config, manual_override) default config</td><td>Where the split came from</td></tr>
</table>
<p>Keys: unique(payslip_id, channel) · index(company_id, payslip_id)</p>
<p>The <strong>resolved</strong> split, frozen at generation for the same reason attendance_snapshots is frozen: changing an employee payment split in October must not silently rewrite what July payslip says was paid. Disbursement (task 8.3b-BE) reads only from here.</p>
<h3>API endpoints</h3>
<pre>GET /api/v1/payroll/payslips/{id}/allocations</pre>
<p>Everything else runs inside the existing generation and regeneration endpoints from task 8.2a-BE. No new write endpoint — allocations are derived, never hand-entered.</p>
<h3>Business rules — salary advance settlement</h3>
<ul>
<li>Advances are <strong>not deductions</strong>. Tax, percentage components and loan installments were all computed on the full gross in task 8.2a-BE, exactly as if no advance existed; the advance is subtracted at the very end, from the net. Netting it earlier would tax an employee on 15,000 when they earned 30,000.</li>
<li><strong>Only advances in status paid are netted.</strong> An approved-but-unhanded-over advance is ignored, because deducting money the employee never received would underpay them; it carries to whichever run first sees it as paid.</li>
<li>advance_paid is the sum of those rows for the employee and the run period; net_payable = max(0, net_pay minus advance_paid).</li>
<li>When the advance exceeded the earned net — typically after heavy unpaid absence — net_payable floors at 0, the excess lands in advance_carry_forward, and the payslip is flagged needs_review.</li>
<li><strong>Nothing on salary_advances is mutated during draft generation.</strong> Settlement stamping (paid to settled) and raising the carry-forward employee_deductions row both happen on run <strong>approval</strong>, in task 8.3a-BE, so that draft regeneration stays repeatable.</li>
<li>The run total_amount becomes the sum of <strong>net_payable</strong>, not net_pay — it is the money the company still has to move. Task 8.2a-BE total is corrected by this task.</li>
</ul>
<h3>Business rules — payment allocation</h3>
<ul>
<li>PaymentAllocator splits net_payable across the employee configured channels and writes payslip_payment_allocations.</li>
<li>Percentages apply to <strong>net_payable, not to gross</strong>. Configuration is entered against gross because that is the figure HR knows, but only the payable money can actually be split.</li>
<li>Each non-residual line takes min(configured, remaining), so a 5,000 fixed cash line in a month where only 3,000 is payable pays 3,000, not 5,000.</li>
<li>The single mandatory residual line absorbs everything left, including rounding, so the allocations always total net_payable to the paisa. This is asserted, not assumed.</li>
<li>An employee with no configured modes falls back to one residual line on their primary bank account — the pre-existing behaviour.</li>
<li>net_payable = 0 produces no allocation rows and no disbursement items. The payslip is still generated and viewable.</li>
<li>Regeneration of a draft replaces the allocations wholesale; the advances themselves are re-read, never mutated.</li>
<li>The payslip PDF from task 8.2a-BE gains three lines — net pay, advance already paid, net payable — plus the channel breakdown.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>class PayslipFinaliser:                 # replaces the 8.2a-BE pass-through
    function finalise(payslip, salary):

        # ---- salary advance settlement (task 7.5-BE) ----
        # NOT a deduction. Everything in 8.2a was computed on the full gross,
        # exactly as if no advance existed. This is money already handed over.
        advances     = SalaryAdvance.where(payslip.employee_id, run.month, run.year,
                                           status: PAID)
        advance_paid = round2(sum(a.amount for a in advances))

        payslip.advance_paid          = advance_paid
        payslip.net_payable           = max(0, payslip.net_pay - advance_paid)
        payslip.advance_carry_forward = max(0, advance_paid - payslip.net_pay)
        if payslip.advance_carry_forward &gt; 0: payslip.needs_review = true
        payslip.save()

        allocatePayment(payslip, salary)

function allocatePayment(payslip, salary):
    modes = PaymentMode.where(salary.id) order by display_order
    if modes is empty:
        account = primaryAccount(payslip.employee_id)
        modes   = [ {channel: channelOf(account), allocation_type: "residual",
                     employee_bank_account_id: account.id} ]

    remaining = payslip.net_payable
    lines     = []

    for m in modes where m.allocation_type != "residual":
        want   = m.allocation_type == "fixed"
                 ? m.value
                 : round2(payslip.net_payable * m.value / 100)   # % of NET PAYABLE, not gross
        amount = min(want, remaining)                            # never overdraw
        if amount &gt; 0:
            lines.append({channel: m.channel, amount,
                          bank_account: m.employee_bank_account_id})
            remaining -= amount

    residual = modes.first(allocation_type == "residual")        # exactly one, per 7.6-BE
    if remaining &gt; 0:
        lines.mergeInto(residual.channel, remaining, residual.employee_bank_account_id)

    assert sum(lines.amount) == payslip.net_payable              # exact, by construction
    replace PayslipPaymentAllocation for payslip.id with lines
    emit PaymentAllocationsFrozen(payslip)</pre>
<h3>Worked example</h3>
<p>Gross 30,000, deductions 3,500, an advance of 15,000 already handed over, split configured as cash 5,000 fixed, cheque 10%, bank residual:</p>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Figure</th><th>Value</th></tr>
<tr><td>gross_earnings</td><td>30,000.00</td></tr>
<tr><td>total_deductions</td><td>3,500.00</td></tr>
<tr><td>net_pay</td><td><strong>26,500.00</strong> — earned; what tax and reports use</td></tr>
<tr><td>advance_paid</td><td>15,000.00</td></tr>
<tr><td>net_payable</td><td><strong>11,500.00</strong> — what disbursement pays</td></tr>
<tr><td>allocations</td><td>cash 5,000.00 + cheque 1,150.00 + bank 5,350.00 = 11,500.00</td></tr>
</table>
<h3>Validation</h3>
<ul>
<li>An employee with a bank or mobile_banking allocation resolving above zero must have a valid account; without one the employee fails individually and is reported, and the run continues.</li>
<li>The payment-mode set is re-validated with the same rule object as task 7.6-BE before allocation; an invalid set fails that employee only.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Allocations not summing to net_payable — 500 with the transaction rolled back; this is an invariant violation, not a user error</li>
<li>A bank or mobile_banking allocation above zero with no valid account — that employee fails with a reason; the run continues</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>The worked example above reproduces exactly: net_pay 26,500, advance_paid 15,000, net_payable 11,500, allocations 5,000 / 1,150 / 5,350.</li>
<li>Income tax on that employee is computed on 26,500-worth of earnings, <strong>not</strong> on 11,500 — asserted directly, since this is the whole point of treating an advance as a payment.</li>
<li>An approved-but-unpaid advance leaves advance_paid at 0; recording its payment and regenerating brings it to 15,000.</li>
<li>An employee whose paid advance exceeds their earned net gets net_payable 0, a non-zero advance_carry_forward and needs_review true.</li>
<li>Draft generation mutates no salary_advances row; regenerating three times leaves them all still paid.</li>
<li>Allocations sum exactly to net_payable in every case, including when a fixed cash line is capped by a low net.</li>
<li>An employee with no configured payment modes gets a single allocation to their primary account.</li>
<li>Changing an employee payment split after generation does not alter an already-generated payslip allocations.</li>
<li>An employee with no advance and no configured split ends with exactly the same payslip figures task 8.2a-BE produced — a regression test proving this task is additive.</li>
<li>The run total_amount equals the sum of net_payable after this task, not the sum of net_pay.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migration for payslip_payment_allocations. <strong>No change to payslips — its columns shipped in task 8.2a-BE.</strong></li>
<li>PaymentAllocator implemented as its own testable service, not inlined in the finaliser, and shared with task 7.6-FE preview through a documented contract.</li>
<li>Property-style test asserting sum(allocations) equals net_payable across a matrix of splits and net values, including zero.</li>
<li>Payslip PDF extended with the settlement lines and channel breakdown.</li>
<li>Regression test asserting no-advance and no-split payslips are unchanged from task 8.2a-BE.</li>
<li>api collection/Payroll/Payslips/allocations-*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s9, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.2-FE Payslip Generation — Frontend',
 'pay-8-2-fe-payslip-generation',
 @vo + 59, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want to generate and review payslips for a run, and as an employee I want to read my own and understand at a glance why the amount in my hand differs from my net pay.</p>
<h3>Start after</h3><p>8.2a-BE Payslip Generation</p>
<h3>Also needs (can be stubbed)</h3><p>8.2b-BE Advance Settlement &amp; Allocation</p>
<h3>Permission</h3><p><code>payroll.run-create</code>, <code>payroll.payslip-view-own</code> / <code>-all</code></p>
<h3>Menu</h3><p><strong>Payroll › Payroll Runs</strong> (payslip list inside the run) <strong>and Payroll › My Payslips</strong> (self-service) — this card builds two surfaces</p>
<h3>Related tables</h3><ul><li>payslips — see task 8.2a-BE; payslip_payment_allocations — see task 8.2b-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/payroll-runs/{id}/payslips
/payroll/payslips/{id}
/payroll/my-payslips</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Generate action</strong> — on a draft run; switches to a progress view with a per-employee failure list.</li>
<li><strong>Payslip list within a run</strong> — employee, gross, deductions, net pay, advance paid, net payable, status, and a review flag column. The run footer totals net_payable, because that is the money still to be moved.</li>
<li><strong>Payslip detail</strong> — full earnings and deductions breakdown, then a distinct <strong>settlement block</strong>: net pay, less advance already paid, net payable, with the advance line linking to its record in task 7.5-FE. Below it, the <strong>payment split panel</strong> showing what each channel receives. Plus the attendance figures used (linked to the snapshot), the salary row used, and print/download.</li>
<li><strong>My payslips</strong> — employee self-service list and detail for their own payslips only, carrying the same settlement block and split panel.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/payroll/payroll-runs/{id}/generate-payslips
GET  /api/v1/payroll/payroll-runs/{id}/generation-status
GET  /api/v1/payroll/payroll-runs/{id}/payslips
GET  /api/v1/payroll/payslips/{id}
GET  /api/v1/payroll/payslips/me?month=&amp;year=
POST /api/v1/payroll/payslips/{id}/regenerate
GET  /api/v1/payroll/payslips/{id}/download</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Generate payslips</strong></td><td>run status = <code>draft</code>, <code>payroll.run-create</code></td><td><code>POST /payroll-runs/{id}/generate-payslips</code></td><td>Progress view; survives navigation</td><td>422 no active tax slab with taxable employees present</td></tr>
<tr><td>2</td><td>Leave and return mid-generation</td><td>batch running</td><td><code>GET /generation-status</code></td><td>Progress view restored</td><td>—</td></tr>
<tr><td>3</td><td><strong>Retry failed employees</strong></td><td>generation finished with failures</td><td><code>POST /generate-payslips</code> scoped to those employees</td><td>Only the failed employees regenerate</td><td>—</td></tr>
<tr><td>4</td><td>Filter <strong>needs review</strong></td><td>always</td><td><code>GET /payroll-runs/{id}/payslips</code></td><td>The rows Finance must look at before approving</td><td>—</td></tr>
<tr><td>5</td><td><strong>Regenerate</strong> one payslip</td><td>status = <code>draft</code></td><td><code>POST /payslips/{id}/regenerate</code></td><td>Recomputed; note states loan installments re-apply idempotently</td><td>409 on a <code>finalized</code> or <code>paid</code> payslip</td></tr>
<tr><td>6</td><td>Open payslip detail</td><td><code>payslip-view-all</code>, or own with <code>-own</code></td><td><code>GET /payslips/{id}</code></td><td>Breakdown, then the <strong>settlement block</strong> (net pay → less advance → net payable) and the channel split</td><td>403 reading someone else’s with only <code>-own</code></td></tr>
<tr><td>7</td><td><strong>Download</strong></td><td>as #6</td><td><code>GET /payslips/{id}/download</code></td><td>PDF carrying the same three settlement lines</td><td>—</td></tr>
<tr><td>8</td><td>Advance line → <strong>Advance record</strong></td><td><code>advance_paid &gt; 0</code></td><td>—</td><td>Navigates to 7.5-FE</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>Generation shows live progress and survives navigation; returning to the run restores the progress view.</li>
<li>Failed employees are listed with their reason and a retry action scoped to those employees only.</li>
<li>Rows flagged needs_review are visually prominent and filterable — they are the ones Finance must look at before approving.</li>
<li>The detail view shows the attendance figures the payslip used, linked to the snapshot, so why-is-this-amount-low is answerable on the spot.</li>
<li>Regenerate appears only on draft payslips, with a note that it re-applies loan installments idempotently.</li>
<li>Employee self-service shows only their own payslips, with no run-level controls.</li>
<li>Amounts render with the payslip own currency_code, not a global default.</li>
<li><strong>Net pay and net payable are never shown as one figure.</strong> The settlement block always renders all three lines, with the advance line at zero and dimmed when there was no advance, so the layout does not shift between employees and nobody reads the wrong number.</li>
<li>The advance line states the handover channel and date inline — this is the screen an employee opens when they ask why they were paid less than their payslip says.</li>
<li>A payslip with advance_carry_forward above zero shows an explicit carried-to-next-month line and the needs_review flag, rather than a bare zero payable.</li>
<li>The split panel shows each channel amount and, for bank lines, the masked account; a capped fixed line is marked as capped rather than silently reduced.</li>
<li>The split panel is hidden entirely when net_payable is zero, replaced by a short explanation.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can generate a full run and see per-employee failures without losing progress on navigation.</li>
<li>A flagged payslip is easy to find and explains why it is flagged.</li>
<li>An employee can view and download their own payslip and cannot reach anyone else.</li>
<li>The detail view links to the attendance data behind the figures.</li>
<li>Net pay, advance paid and net payable are always three visible lines, including when the advance is zero.</li>
<li>The channel amounts shown sum to the net payable on screen, matching the API to the paisa.</li>
<li>An employee with a carried-forward advance sees it stated, not just a zero payable.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/payslipApi.ts</code> with generation-status polling.</li>
<li>My-payslips entry under Payroll for all employees.</li>
<li>The split display reuses the shared allocation helper from task 7.6-FE rather than reformatting the rules a second time.</li>
</ul>',
 16.00, 'todo', 'medium', @s9, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.3a-BE Run Approval & Advance Settlement — Backend',
 'pay-8-3a-be-run-approval',
 @vo + 60, @type_be,
 '<h3>Summary</h3><p>As Finance, I want to approve a payroll run so its payslips are finalised and every advance already paid is closed out against them.</p>
<p><strong>Split note.</strong> Split from the original 13-point 8.3-BE. This task closes the run and settles advances; <strong>8.3b-BE</strong> moves the money. An approved run is a complete, useful state on its own — Finance can see final figures and lock the month before disbursement exists.</p>
<h3>Start after</h3><p>8.2b-BE Advance Settlement &amp; Allocation · 4.1-BE Approval Integration</p>
<h3>Also needs (can be stubbed)</h3><p>7.5-BE Salary Advances</p>
<h3>Permission</h3><p><code>payroll.run-approve</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 8.3a-FE</p>
<h3>Related tables</h3><ul><li>payroll_runs, payslips, salary_advances, employee_deductions, approval_requests — all existing. <strong>No new tables, no new columns.</strong></li></ul>
<h3>API endpoints</h3>
<pre>GET /api/v1/payroll/payroll-runs/{id}/approval-summary</pre>
<p>Approval uses the platform existing approve/reject endpoints, as elsewhere. <strong>No approve route is added here.</strong></p>
<pre>POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject</pre>
<p>approval-summary returns what the approval will do before it happens: payslip count, needs_review count, total net_pay, total net_payable, number and value of advances that will settle, and number of carry-forward recoveries that will be raised.</p>
<h3>Business rules</h3>
<ul>
<li>A run is routed through the approval engine with actionSlug run-approve and correlationId payroll_run:{id}.</li>
<li>PayrollRunExecutor (stub from task 4.1-BE) is implemented here: it sets the run to approved and every payslip in it to finalized, locking both against edits.</li>
<li><strong>The executor also closes out salary advances</strong>, which is why draft generation deliberately leaves them alone (task 8.2b-BE). In the same transaction, for every payslip in the run: each paid advance that was netted moves to settled with settled_payslip_id and settled_at stamped and emits SalaryAdvanceSettled; and any advance_carry_forward above zero raises an employee_deductions row of type advance for the excess, starting the following month, so the over-advance is recovered rather than written off.</li>
<li><strong>Settlement is idempotent.</strong> Replaying the executor moves nothing a second time and raises no duplicate carry-forward row — guarded by settled_at being already set and by a lookup on the carry-forward deduction remarks reference.</li>
<li>Approval is blocked if any payslip in the run is still draft and generation is incomplete — a partially generated run must not be approved.</li>
<li>Rejection requires a reason, leaves every payslip draft, and settles nothing.</li>
<li>Emits PayrollRunApproved and PayslipPublished.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>class PayrollRunExecutor:
    function execute(payload):
        run = PayrollRun.lockForUpdate(payload.entity_id)
        if run.status != PENDING_APPROVAL:
            raise Error("Run already finalized")

        transaction:
            for p in Payslip.where(run.id):
                p.status = FINALIZED
                p.save()

                for a in SalaryAdvance.where(p.employee_id, run.month, run.year,
                                             status: PAID):
                    if a.settled_at: continue                 # idempotent replay guard
                    a.status = SETTLED; a.settled_payslip_id = p.id; a.settled_at = now()
                    a.save()
                    emit SalaryAdvanceSettled(a)

                if p.advance_carry_forward &gt; 0
                   and not EmployeeDeduction.existsForPayslip(p.id):
                    EmployeeDeduction.create({
                        employee: p.employee_id, type: "advance",
                        total_amount:       p.advance_carry_forward,
                        remaining_balance:  p.advance_carry_forward,
                        installment_amount: p.advance_carry_forward,
                        start_month: nextMonth(run), start_year: nextYear(run),
                        status: ACTIVE,
                        remarks: "Over-advance carried from payslip " + p.id })

            run.status      = APPROVED
            run.approved_by = actor
            run.save()

        emit PayrollRunApproved(run)</pre>
<h3>Validation</h3>
<ul>
<li>Every payslip in the run must be draft and generation must be reported complete.</li>
<li>Rejection requires decision_reason, max 500.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Approving a run whose generation is incomplete — 409 naming the outstanding employee count</li>
<li>Run already finalised — 409</li>
<li>Rejection without a reason — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A run cannot reach approved without going through the approval engine.</li>
<li>Approving a run locks it and every payslip in it against further edits.</li>
<li>Approving a run moves every netted advance to settled with its payslip stamped, exactly once even if approval is replayed.</li>
<li>An over-advanced employee gets an employee_deductions advance row for the excess, starting the following month, and replaying approval raises no second row.</li>
<li>Rejecting a run leaves every payslip draft and every advance still paid.</li>
<li>approval-summary states the advance settlement impact before the decision is made.</li>
<li>A run with an incomplete generation cannot be approved.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>PayrollRunExecutor implemented, registered and tested through both the bypass and workflow paths.</li>
<li>Advance settlement replay test: approve, replay the executor, assert one settled transition and one carry-forward deduction row.</li>
<li>Rejection path tested for zero side effects.</li>
<li>api collection/Payroll/Payroll Runs/approval-*.yml</li>
</ul>',
 10.00, 'todo', 'medium', @s10, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.3b-BE Multi-Channel Disbursement — Backend',
 'pay-8-3b-be-disbursement',
 @vo + 61, @type_be,
 '<h3>Summary</h3><p>As Finance, I want to disburse an approved run across cash, cheque and bank in separate batches, with failed payments retryable without paying anyone twice and cash handovers acknowledged on record.</p>
<p><strong>Split note.</strong> Split from the original 13-point 8.3-BE. <strong>Task 8.2b-BE is a hard prerequisite</strong> — this task reads payslip_payment_allocations, which does not exist without it. Building on top of task 8.2a-BE alone would pay every employee their full net_pay, ignoring advances.</p>
<h3>Start after</h3><p>8.3a-BE Run Approval · 8.2b-BE Advance Settlement &amp; Allocation</p>
<h3>Permission</h3><p><code>payroll.disburse</code></p>
<h3>Menu</h3><p>none — API only; surfaces in 8.3b-FE</p>
<h3>Related tables</h3>
<ul>
<li><code>disbursement_batches</code> (new)</li>
<li><code>disbursement_batch_items</code> (new)</li>
<li><code>payslip_payment_allocations</code> — the frozen split this reads, see task 8.2b-BE</li>
<li>payroll_runs, payslips (existing)</li>
</ul>
<h3>DB schema — disbursement_batches</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>company_id</td><td>unsignedBigInteger</td><td>Multi-tenant scope</td></tr>
<tr><td>payroll_run_id</td><td>unsignedBigInteger</td><td>Which run this batch pays</td></tr>
<tr><td>batch_reference</td><td>varchar(100)</td><td>Bank or gateway reference</td></tr>
<tr><td>payout_channel</td><td>enum(bank, mobile_banking, cash, cheque)</td><td>One batch per channel</td></tr>
<tr><td>total_amount</td><td>decimal(15,2)</td><td>Sum disbursed in this batch</td></tr>
<tr><td>status</td><td>enum(pending, sent, confirmed, failed)</td><td>Batch state</td></tr>
<tr><td>sent_at</td><td>datetime nullable</td><td>When sent</td></tr>
<tr><td>failure_reason</td><td>text nullable</td><td>Why the batch failed</td></tr>
</table>
<h3>DB schema — disbursement_batch_items</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>id</td><td>bigint PK</td><td>Primary key</td></tr>
<tr><td>disbursement_batch_id</td><td>unsignedBigInteger</td><td>Parent batch</td></tr>
<tr><td>payslip_id</td><td>unsignedBigInteger</td><td>Payslip being paid</td></tr>
<tr><td>payslip_payment_allocation_id</td><td>unsignedBigInteger</td><td>The frozen allocation this item pays</td></tr>
<tr><td>employee_id</td><td>unsignedBigInteger</td><td><strong>employee_personal_infos.id</strong></td></tr>
<tr><td>employee_bank_account_id</td><td>unsignedBigInteger <strong>nullable</strong></td><td>Destination account; null for cash and cheque</td></tr>
<tr><td>amount</td><td>decimal(12,2)</td><td>This channel slice for this employee</td></tr>
<tr><td>payment_reference</td><td>varchar(100) nullable</td><td>Cheque no. / txn id / voucher no.</td></tr>
<tr><td>acknowledged_by, acknowledged_at</td><td>unsignedBigInteger, datetime nullable</td><td>Cash receipt acknowledgement</td></tr>
<tr><td>status</td><td>enum(pending, sent, confirmed, failed)</td><td>Item state</td></tr>
<tr><td>failure_reason</td><td>text nullable</td><td>Per-item failure</td></tr>
</table>
<p>Keys: unique(payslip_payment_allocation_id) · index(disbursement_batch_id, status)</p>
<p>Items exist so a retry re-sends only the failed rows. A batch-level retry without them would re-pay everyone.</p>
<p>The unique key is on the <strong>allocation</strong>, not on (batch, payslip): <strong>one payslip can legitimately appear in three batches</strong>, once per channel, and it is the allocation that must be paid exactly once. Keying on the payslip would have made a split payroll impossible.</p>
<h3>API endpoints</h3>
<pre>GET  /api/v1/payroll/payroll-runs/{id}/disbursement-readiness
GET  /api/v1/payroll/payroll-runs/{id}/channel-summary
POST /api/v1/payroll/payroll-runs/{id}/disburse
GET  /api/v1/payroll/disbursement-batches?payroll_run_id=&amp;status=
GET  /api/v1/payroll/disbursement-batches/{id}
POST /api/v1/payroll/disbursement-batches/{id}/send
POST /api/v1/payroll/disbursement-batches/{id}/confirm
POST /api/v1/payroll/disbursement-batches/{id}/retry
POST /api/v1/payroll/disbursement-batches/{id}/acknowledge-all
POST /api/v1/payroll/disbursement-batch-items/{id}/acknowledge
GET  /api/v1/payroll/disbursement-batches/{id}/export</pre>
<p>channel-summary totals the run allocations by channel before any batch exists, so Finance knows how much cash to draw.</p>
<h3>Business rules</h3>
<ul>
<li>Disbursement requires the run to be approved (task 8.3a-BE).</li>
<li><strong>Batches are built from payslip_payment_allocations, not from payslips.</strong> One batch per distinct channel present in the run, so a company paying part cash and part bank gets two batches from a single run, and each allocation produces exactly one item.</li>
<li><strong>The readiness rule is channel-aware.</strong> An employee is blocked only when they have a bank or mobile_banking allocation above zero and no valid account for it. <strong>An employee paid entirely in cash needs no bank account and must not be excluded</strong> — the original no-primary-payment-method rule would have wrongly blocked them. disbursement-readiness reports only genuine gaps, each linked to the employee bank-account screen.</li>
<li>Payslips with net_payable = 0 have no allocations and therefore no items; they are reported as fully settled by advance, not as failures.</li>
<li>A batch total_amount must equal the sum of its items; asserted on creation.</li>
<li>retry re-sends only items with status failed, and never re-sends a confirmed item.</li>
<li><strong>cash and cheque batches are registers, not transmissions.</strong> Nothing is sent anywhere. send and retry do not apply to them; their items move from pending to confirmed through acknowledge, stamping acknowledged_by and acknowledged_at and payment_reference for cheque numbers, or in bulk through acknowledge-all once a signed register is reconciled. They exist so a cash payroll is auditable in the same shape as a bank payroll.</li>
<li>A payslip becomes paid only when <strong>every</strong> item across <strong>all</strong> of its batches is confirmed. A bank leg confirming while the cash leg is outstanding does not close the payslip. When every item of every batch is confirmed, the run becomes paid.</li>
<li>Re-running disburse on a run that already has batches creates no duplicate items — the unique key on the allocation is the guard.</li>
<li>export produces the bank-format file for bank and mobile-banking batches, and a signable disbursement register (cash) or cheque schedule (cheque) for the other two.</li>
<li>Emits PayslipPaid and, when the last item confirms, PayrollRunPaid.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function buildDisbursement(run):
    if run.status != APPROVED:
        raise Error("Run must be approved before disbursement")

    # allocations, NOT payslips - one payslip may span several channels
    allocations = PayslipPaymentAllocation.forRun(run.id).where(amount greater than 0)

    # channel-aware readiness: only bank-like channels need an account
    missing = allocations.filter(a =&gt; a.channel in ["bank", "mobile_banking"]
                                      and not validAccount(a.employee_bank_account_id))
    payable = allocations.except(missing)

    batches = []
    transaction:
        for (channel, items) in groupBy(payable, a =&gt; a.channel):
            batch = DisbursementBatch.create({ run, payout_channel: channel,
                                               total_amount: sum(items.amount),
                                               status: PENDING })
            for a in items:
                DisbursementBatchItem.create({ batch, allocation: a.id, payslip: a.payslip_id,
                                               employee: a.employee_id,
                                               account: a.employee_bank_account_id,  # null for cash/cheque
                                               amount: a.amount, status: PENDING })
            assert batch.total_amount == sum(batch.items.amount)
            batches.push(batch)

    return { batches, excluded: missing }

function retry(batch):
    if batch.payout_channel in ["cash", "cheque"]:
        raise Error("Register batches are acknowledged, not retried")
    failed = batch.items.where(status: FAILED)
    if failed is empty:
        raise Error("No failed items to retry")
    send(failed)                       # confirmed and sent items are untouched

function acknowledge(item, reference):
    if item.batch.payout_channel not in ["cash", "cheque"]:
        raise Error("Only register batches are acknowledged")
    if item.batch.payout_channel == "cheque" and not reference:
        raise Error("Cheque number required")

    item.payment_reference = reference
    item.acknowledged_by   = currentUser
    item.acknowledged_at   = now()
    item.status            = CONFIRMED
    item.save()
    closePayslipIfFullyConfirmed(item.payslip_id)      # ALL items, ALL batches</pre>
<h3>Validation</h3>
<ul>
<li>Disburse — run must be approved and have at least one allocation above zero.</li>
<li>Confirm — batch must be sent, and must not be a cash or cheque register.</li>
<li>Retry — batch must be failed or contain failed items, and must not be a register.</li>
<li>Acknowledge — batch must be cash or cheque; the item must not already be confirmed; a cheque item requires a reference.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Disbursing a run that is not approved — 409</li>
<li>Confirming a batch that was never sent — 409</li>
<li>Retrying a batch with no failed items — 422</li>
<li>send, confirm or retry on a cash or cheque register — 409 stating that registers are acknowledged instead</li>
<li>acknowledge on a bank or mobile-banking batch — 409</li>
<li>Acknowledging a cheque item without a reference — 422</li>
<li>Batch total not matching item sum — 500 with the transaction rolled back; this is an invariant violation, not a user error</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li><strong>An employee paid entirely in cash is not reported as missing a payment method and is not excluded</strong> — this is the specific regression the old rule would have caused.</li>
<li>An employee with a bank allocation and no valid account is reported and excluded, never silently omitted.</li>
<li>A run with cash, cheque and bank allocations produces exactly three batches whose totals sum to the run net_payable total.</li>
<li>One payslip split across three channels produces exactly three items, one per allocation, and re-running disburse creates no duplicates.</li>
<li>Batch total equals the sum of its items, asserted on creation.</li>
<li>Retrying a partially failed bank batch re-sends only the failed items; confirmed items are untouched and no employee is paid twice.</li>
<li>send on a cash register returns 409; acknowledging its items individually confirms them.</li>
<li>A payslip whose bank leg is confirmed but whose cash leg is not stays unpaid; confirming the cash leg closes it.</li>
<li>A payslip with net_payable = 0 produces no items and is reported as settled by advance rather than as a failure.</li>
<li>channel-summary totals match the batches subsequently created, to the paisa.</li>
<li>A run becomes paid only when every item of every batch is confirmed.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Migrations for both tables.</li>
<li>Retry idempotency test: fail three of ten items, retry, assert exactly three sends and ten total payments.</li>
<li>Split-payslip test: one employee across cash plus cheque plus bank, asserting three items and payslip closure only after all three confirm.</li>
<li>Cash-only-employee readiness test, asserting they are neither reported nor excluded.</li>
<li>Double-disburse test asserting the allocation unique key prevents duplicate items.</li>
<li>Bank-format export implemented for at least one bank channel, plus a printable cash register and cheque schedule.</li>
<li>api collection/Payroll/Disbursement/*.yml</li>
</ul>',
 16.00, 'todo', 'medium', @s10, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.3a-FE Run Approval & Readiness — Frontend',
 'pay-8-3a-fe-run-approval',
 @vo + 62, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want to see exactly what approving a run will do — final figures, flagged payslips, advances that will settle — before I commit to it.</p>
<p><strong>Split note.</strong> Split from the original 8-point 8.3-FE. This task is the decision screen; <strong>8.3b-FE</strong> is the operational screen for moving money. They share no components beyond the shared badges.</p>
<h3>Start after</h3><p>8.3a-BE Run Approval</p>
<h3>Also needs (can be stubbed)</h3><p>4.1-FE Approval Settings</p>
<h3>Permission</h3><p><code>payroll.run-approve</code></p>
<h3>Menu</h3><p><strong>Payroll › Payroll Runs</strong> — the approval screen on the run detail</p>
<h3>Related tables</h3><ul><li>payroll_runs, payslips, salary_advances — see tasks 8.3a-BE and 8.2b-BE.</li></ul>
<h3>Frontend routes</h3><pre>/payroll/payroll-runs/{id}/approval</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Run approval screen</strong> — run summary, payslip totals showing net pay and net payable separately, the total advance this approval settles, a needs_review count, and Approve / Reject with a mandatory rejection reason.</li>
<li><strong>Flagged payslip list</strong> — the needs_review rows inline, each linking to its payslip, so approving past a flag is a deliberate act rather than an overlooked number.</li>
<li><strong>Advance settlement preview</strong> — how many advances will move to settled, their total, and how many carry-forward recoveries will be raised.</li>
<li><strong>Approval trail</strong> — the shared ApprovalStatusBadge and step history from task 4.1-FE.</li>
</ul>
<h3>API integration</h3>
<pre>GET  /api/v1/payroll/payroll-runs/{id}
GET  /api/v1/payroll/payroll-runs/{id}/approval-summary
GET  /api/v1/payroll/payroll-runs/{id}/payslips?needs_review=true
POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject
GET  /api/v1/approval-requests/{id}/history</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Open the approval screen</td><td><code>payroll.run-approve</code></td><td><code>GET /payroll-runs/{id}/approval-summary</code></td><td>Net pay and net payable as <strong>two separate totals</strong>, <code>needs_review</code> count, and the advance settlement impact</td><td>—</td></tr>
<tr><td>2</td><td>Click the review count</td><td>count &gt; 0</td><td><code>GET /payslips?needs_review=true</code></td><td>Flagged rows inline, each linking to its payslip</td><td>—</td></tr>
<tr><td>3</td><td><strong>Approve</strong></td><td>generation complete</td><td><code>POST /approval-requests/{id}/approve</code></td><td>Run <code>approved</code>, payslips <code>finalized</code>, advances <code>settled</code>, carry-forwards raised</td><td>409 incomplete generation → outstanding count named</td></tr>
<tr><td>4</td><td><strong>Approve</strong> — the dialog</td><td>—</td><td>—</td><td>States it will settle N advances totalling X and raise M carry-forward recoveries, because this is where it becomes irreversible</td><td>—</td></tr>
<tr><td>5</td><td><strong>Reject</strong></td><td>always</td><td><code>POST /approval-requests/{id}/reject</code></td><td>Payslips stay <code>draft</code>, advances stay <code>paid</code></td><td>422 empty reason</td></tr>
<tr><td>6</td><td>After approval</td><td>—</td><td>—</td><td>Screen becomes read-only and links forward to disbursement (8.3b-FE)</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>The needs_review count is prominent and linked — approving past a flagged payslip should be a conscious act.</li>
<li><strong>Net pay and net payable are shown as two separate totals</strong>, never merged, so nobody approves believing the company is about to move the larger figure.</li>
<li>The approval dialog states that approving will settle N advances totalling X and raise M carry-forward recoveries, because approval is where that becomes irreversible.</li>
<li>Reject requires a typed reason with no default text and cannot be submitted empty.</li>
<li>Approve is disabled with a stated reason while generation is incomplete.</li>
<li>Once the run is approved, the screen becomes read-only and links forward to the disbursement screen (task 8.3b-FE).</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Finance can see the flagged-payslip count and the advance settlement impact before approving.</li>
<li>Net pay and net payable are visibly distinct totals.</li>
<li>Rejecting without a reason is impossible.</li>
<li>An incompletely generated run cannot be approved, and the screen says why.</li>
<li>An approved run offers no further approval actions and points to disbursement.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/payrollRunApi.ts</code> extended with the approval-summary call.</li>
<li>Reuses ApprovalStatusBadge from task 4.1-FE.</li>
</ul>',
 10.00, 'todo', 'medium', @s10, NULL, NULL, @now, @now),

(@project_id, @mod_payroll,
 '8.3b-FE Disbursement Batches & Registers — Frontend',
 'pay-8-3b-fe-disbursement',
 @vo + 63, @type_fe,
 '<h3>Summary</h3><p>As Finance, I want to drive disbursement channel by channel, printing a signable register for the cash and cheque legs and seeing clearly who is excluded and what failed.</p>
<p><strong>Split note.</strong> Split from the original 8-point 8.3-FE. This task owns everything after approval — readiness, batch creation, and the two very different batch detail screens.</p>
<h3>Start after</h3><p>8.3b-BE Disbursement</p>
<h3>Also needs (can be stubbed)</h3><p>8.3a-FE Run Approval screen</p>
<h3>Permission</h3><p><code>payroll.disburse</code></p>
<h3>Menu</h3><p><strong>Payroll › Disbursement</strong></p>
<h3>Related tables</h3><ul><li>disbursement_batches, disbursement_batch_items, payslip_payment_allocations — see tasks 8.3b-BE and 8.2b-BE.</li></ul>
<h3>Frontend routes</h3>
<pre>/payroll/disbursement-batches
/payroll/disbursement-batches/{id}</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Channel summary panel</strong> — before disbursing, the run money broken down by channel with a per-channel total, so Finance knows how much cash to draw before anything is created.</li>
<li><strong>Disbursement readiness panel</strong> — only employees with a genuine gap, that is a bank or mobile-banking slice with no valid account, each linked to their bank-account screen. Employees paid entirely in cash never appear here.</li>
<li><strong>Batch list</strong> — channel, reference, total, status, item counts by state, with register batches visually distinguished from transmitted ones.</li>
<li><strong>Batch detail (bank / mobile banking)</strong> — item table with per-employee status and failure reason; Send, Confirm, Retry and Export actions.</li>
<li><strong>Batch detail (cash / cheque register)</strong> — item table with an acknowledgement column, a per-row Acknowledge action, a cheque-number field on cheque rows, a bulk Acknowledge all, and a Print register action producing a signable sheet.</li>
</ul>
<h3>API integration</h3>
<pre>GET  /api/v1/payroll/payroll-runs/{id}/disbursement-readiness
GET  /api/v1/payroll/payroll-runs/{id}/channel-summary
POST /api/v1/payroll/payroll-runs/{id}/disburse
GET  /api/v1/payroll/disbursement-batches?payroll_run_id=
GET  /api/v1/payroll/disbursement-batches/{id}
POST /api/v1/payroll/disbursement-batches/{id}/send
POST /api/v1/payroll/disbursement-batches/{id}/confirm
POST /api/v1/payroll/disbursement-batches/{id}/retry
POST /api/v1/payroll/disbursement-batches/{id}/acknowledge-all
POST /api/v1/payroll/disbursement-batch-items/{id}/acknowledge
GET  /api/v1/payroll/disbursement-batches/{id}/export</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td>Review <strong>channel summary</strong></td><td>run <code>approved</code></td><td><code>GET /payroll-runs/{id}/channel-summary</code></td><td>Per-channel totals <strong>before</strong> any batch exists, so Finance knows how much cash to draw</td><td>—</td></tr>
<tr><td>2</td><td>Review <strong>readiness</strong></td><td>run <code>approved</code></td><td><code>GET /disbursement-readiness</code></td><td>Only genuine gaps; panel states cash-paid employees are not listed, so empty ≠ broken</td><td>—</td></tr>
<tr><td>3</td><td><strong>Disburse</strong></td><td><code>payroll.disburse</code></td><td><code>POST /payroll-runs/{id}/disburse</code></td><td>One batch per channel present in the run</td><td>409 run not approved</td></tr>
<tr><td>4</td><td><strong>Send</strong></td><td>bank / mobile batch, status <code>pending</code></td><td><code>POST /batches/{id}/send</code></td><td>Batch <code>sent</code>; dialog names total and item count</td><td>409 on a cash or cheque register</td></tr>
<tr><td>5</td><td><strong>Confirm</strong></td><td>bank / mobile batch, status <code>sent</code></td><td><code>POST /batches/{id}/confirm</code></td><td>Items confirmed</td><td>409 if never sent</td></tr>
<tr><td>6</td><td><strong>Retry</strong></td><td>bank / mobile batch with failed items</td><td><code>POST /batches/{id}/retry</code></td><td>Only failed items re-sent; dialog states the exact count and that confirmed items are untouched</td><td>422 no failed items</td></tr>
<tr><td>7</td><td><strong>Acknowledge</strong> one row</td><td>cash or cheque register</td><td><code>POST /batch-items/{id}/acknowledge</code></td><td>Item <code>confirmed</code>, acknowledger and time stamped</td><td>422 cheque row without a cheque number</td></tr>
<tr><td>8</td><td><strong>Acknowledge all</strong></td><td>cash or cheque register</td><td><code>POST /batches/{id}/acknowledge-all</code></td><td>All items confirmed; dialog names count and total and calls it a bookkeeping action</td><td>—</td></tr>
<tr><td>9</td><td><strong>Print register</strong></td><td>cash or cheque batch</td><td><code>GET /batches/{id}/export</code></td><td>Signable sheet — name, id, amount, signature column (cheque schedule adds the number column)</td><td>—</td></tr>
<tr><td>10</td><td><strong>Export</strong></td><td>bank / mobile batch</td><td><code>GET /batches/{id}/export</code></td><td>Bank-format file</td><td>—</td></tr>
</table>
<p><strong>Deliberately absent on register batches:</strong> Send and Retry are <strong>not rendered</strong>. A disabled Send on a cash batch would imply money moves through the system; it does not.</p>
<h3>UI rules</h3>
<ul>
<li>Readiness is shown before the disburse action is offered, with each excluded employee one click from being fixed. The panel says explicitly that cash-paid employees are not listed, so an empty panel is not mistaken for a broken check.</li>
<li>Retry states exactly how many items it will re-send and confirms that confirmed items are untouched, because the fear this screen must answer is double payment.</li>
<li>Item statuses are colour-coded and filterable; the failure reason is shown inline, not behind a hover.</li>
<li>Send and Confirm require confirmation dialogs naming the batch total and item count.</li>
<li><strong>Register batches never show Send or Retry.</strong> Offering a disabled Send on a cash batch would imply money moves through the system; it does not.</li>
<li>The printed cash register carries employee name, id, amount and a signature column, and the cheque schedule carries the cheque-number column — these are the physical artefacts the money moves against.</li>
<li>Bulk Acknowledge all names the count and total and states that it is a bookkeeping action recording handovers that already happened.</li>
<li>A partly confirmed payslip is shown as partly confirmed across its channels, not as unpaid, so Finance can see which leg is outstanding.</li>
<li>Once a run is paid, all mutating actions disappear from the screen rather than being disabled.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>The per-channel totals are visible before any batch is created.</li>
<li>Excluded employees are visible and fixable before batches are created, and cash-only employees are never among them.</li>
<li>Retrying a partially failed batch clearly states its scope before running.</li>
<li>A cash batch offers Acknowledge and Print, and offers neither Send nor Retry.</li>
<li>A cheque row cannot be acknowledged without a cheque number.</li>
<li>A payslip with one confirmed and one outstanding leg is shown as partly paid.</li>
<li>A paid run offers no mutating actions.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/payroll/api/disbursementApi.ts</code></li>
<li>Print stylesheet for the cash register and cheque schedule, verified against an A4 print preview.</li>
<li>Nav entry under Payroll.</li>
</ul>',
 10.00, 'todo', 'medium', @s10, NULL, NULL, @now, @now);

-- =====================================================================
--  PART J — AUTOMATION  (4 tasks, 16 points)
--  Absent from the original document. Without these the module does not
--  function: no Absent day is ever created, no leave ever accrues, and no
--  attendance type exists to calculate against.
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_attendance,
 '9.1-BE Nightly Attendance Close — Backend',
 'att-9-1-be-nightly-close',
 @vo + 64, @type_be,
 '<h3>Summary</h3><p>As the system, I want a nightly job that closes the previous day for every employee, because calculateDaily only runs on a punch — and an absent employee never punches.</p>
<h3>Start after</h3><p>3.2-BE Daily Summary</p>
<h3>Permission</h3><p>n/a (scheduled job). Manual trigger requires <code>attendance.record-recalculate</code>.</p>
<h3>Menu</h3><p>none — API only; surfaces in 9.4-FE</p>
<h3>Related tables</h3><ul><li>attendance_records, attendance_punches (existing)</li></ul>
<h3>API endpoints</h3>
<pre>POST /api/v1/attendance/jobs/close-day
     body: { "date": "2026-07-26", "employee_id": null }
GET  /api/v1/attendance/jobs/close-day/{batchId}</pre>
<p>Manual trigger for backfilling; the normal path is the scheduler.</p>
<h3>Business rules</h3>
<ul>
<li>Runs per company at <strong>02:00 company-local time</strong>, using companies.timezone (task 0.2-BE). A single global 02:00 UTC schedule would close the wrong day for some tenants.</li>
<li>For the previous day, for every active employee with no attendance_records row, runs calculateDaily (task 3.2-BE). This is what actually creates Absent, Holiday and Weekend rows.</li>
<li>Dates that resolve as unassigned are <strong>skipped and reported</strong>, never defaulted to a shift.</li>
<li>Locked records are skipped.</li>
<li>Idempotent: re-running produces no changes for days already closed.</li>
<li>Emits a summary to the activity log: employees processed, rows created, rows skipped, unassigned count.</li>
<li>Chunked over employees so a large tenant does not exhaust memory.</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function closeDay(company_id, date):
    stats = { processed: 0, created: 0, skipped_locked: 0, unassigned: [] }

    for employee in activeEmployees(company_id, asOf: date).chunk(200):
        existing = AttendanceRecord.find(employee.id, date)
        if existing and existing.is_locked:
            stats.skipped_locked++; continue

        context = AssignmentResolver.resolve(employee.id, date)
        if context.unassigned:
            stats.unassigned.push(employee.id); continue        # never defaulted

        calculateDaily(employee.id, date)                        # task 3.2-BE
        stats.processed++

    activityLog("attendance.day_closed", company_id, date, stats)
    return stats</pre>
<h3>Validation</h3>
<ul>
<li>date — not in the future; at most 90 days in the past for a manual run.</li>
<li>employee_id — optional; when given, closes that employee only.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>A failure on one employee is logged and the job continues; it does not abort the batch.</li>
<li>Manual run for a future date — 422</li>
<li>Manual run beyond 90 days — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>An employee who never punched on a working day has an Absent record by the next morning.</li>
<li>A weekend produces a Weekend record, not an Absent one.</li>
<li>An employee with no shift assigned is reported as unassigned and gets no record at all.</li>
<li>Re-running the job for the same date produces no changes and no duplicate rows.</li>
<li>A locked day is skipped even if it has no record.</li>
<li>Two companies in different timezones each close their own previous day correctly.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Scheduler registration per company timezone, with a test covering two tenants in different zones.</li>
<li>Idempotency test running the job twice and asserting identical state.</li>
<li>Unassigned employees surfaced in a report HR can read, not only in logs.</li>
</ul>',
 10.00, 'todo', 'medium', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '9.2-BE Leave Accrual & Carry-Forward — Backend',
 'att-9-2-be-leave-accrual',
 @vo + 65, @type_be,
 '<h3>Summary</h3><p>As HR, I want leave to accrue and carry forward automatically according to each policy, because the balance table cannot maintain itself.</p>
<h3>Start after</h3><p>5.1-BE Leave Balances</p>
<h3>Also needs (can be stubbed)</h3><p>1.3-BE Policies</p>
<h3>Permission</h3><p>Manual trigger requires <code>attendance.leave-balance-adjust</code>.</p>
<h3>Menu</h3><p>none — API only; surfaces in 9.4-FE</p>
<h3>Related tables</h3><ul><li>leave_balances, leave_balance_ledger, policies (existing)</li></ul>
<h3>API endpoints</h3>
<pre>POST /api/v1/attendance/jobs/accrue-leave
     body: { "month": 7, "year": 2026, "policy_id": null }
POST /api/v1/attendance/jobs/carry-forward
     body: { "from_year": 2026, "policy_id": null }
GET  /api/v1/attendance/jobs/{batchId}</pre>
<p>Manual triggers exist for backfilling; the normal path is the scheduler.</p>
<h3>Business rules</h3>
<ul>
<li><strong>Monthly accrual</strong>, on the 1st at 01:00 company-local, for policies with accrual_method = monthly: adds entitlement_days divided by 12, writes an accrual ledger row.</li>
<li><strong>Annual accrual</strong>, for accrual_method = annual: the full entitlement is granted when the year balance row is created.</li>
<li><strong>On joining</strong>, for accrual_method = on_joining: pro-rated from the joining date for the first year.</li>
<li>Accrual is <strong>idempotent per (employee, policy, year, month)</strong> — the ledger is checked before writing, so a re-run adds nothing.</li>
<li><strong>Year-end carry-forward</strong>, on 1 January at 02:00 company-local: carry equals the smaller of available and policy max_carry_forward, written into the next year balance as a carry_forward ledger row.</li>
<li>Carry-forward runs only for policies with carry_forward_allowed = true; others start the new year at zero carried forward.</li>
<li>Balance rows for the new year are created by this job for every employee holding an active leave-policy assignment.</li>
<li>Every mutation writes a ledger row in the same transaction (task 5.1-BE).</li>
</ul>
<h3>Calculation pseudocode</h3>
<pre>function accrueMonthly(company_id, month, year):
    for (employee, policy) in activeLeaveAssignments(company_id, asOf: endOf(month, year)):
        if policy.config.accrual_method != "monthly": continue
        if Ledger.exists(employee, policy, year, month, type: "accrual"): continue

        rate    = policy.config.entitlement_days / 12
        balance = getOrCreateBalance(employee, policy, year)

        transaction:
            balance.entitled_days += rate
            balance.save()
            Ledger.create({ balance, entry_type: "accrual", days: rate,
                            reference_type: "accrual", reference_id: month })

function yearEndCarryForward(company_id, from_year):
    for (employee, policy) in activeLeaveAssignments(company_id, asOf: endOf(from_year)):
        old = getBalance(employee, policy, from_year)
        new = getOrCreateBalance(employee, policy, from_year + 1)

        if Ledger.exists(new, type: "carry_forward"): continue

        carry = policy.config.carry_forward_allowed
                ? min(old.available, policy.config.max_carry_forward)
                : 0

        transaction:
            new.carried_forward_days = carry
            new.save()
            Ledger.create({ balance: new, entry_type: "carry_forward", days: carry,
                            reference_type: "leave_balance", reference_id: old.id })</pre>
<h3>Validation</h3>
<ul>
<li>month 1 to 12; year and from_year within plus or minus 5 of the current year.</li>
<li>Manual triggers require attendance.leave-balance-adjust.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>A failure on one employee is logged and the job continues.</li>
<li>Re-running an already-applied accrual returns a count of zero applied rather than an error.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A monthly-accrual policy adds one twelfth of the entitlement each month, and running the job twice in one month adds it once.</li>
<li>Year-end carry-forward respects the policy cap.</li>
<li>A policy with carry-forward disabled starts the new year with zero carried forward.</li>
<li>New-year balance rows exist for every employee with an active leave assignment after the job runs.</li>
<li>The ledger reconciles to the balance columns after both jobs, asserted by the task 5.1-BE test.</li>
<li>A manual backfill for a past month produces the same result as the scheduled run would have.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Scheduler registration for both jobs, per company timezone.</li>
<li>Idempotency tests for both, running each twice.</li>
<li>All three accrual methods unit-tested, including a mid-year joiner.</li>
</ul>',
 10.00, 'todo', 'medium', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '9.3-BE Default Data Seeders — Backend',
 'att-9-3-be-default-seeders',
 @vo + 66, @type_be,
 '<h3>Summary</h3><p>As a new tenant, I want the eleven system attendance types and one default shift to exist, because the calculation engine resolves statuses by system_code and cannot run without them.</p>
<h3>Start after</h3><p>1.1-BE Attendance Types · 1.2-BE Shifts</p>
<h3>Permission</h3><p>n/a (seeder)</p>
<h3>Menu</h3><p><strong>none anywhere</strong> — seeder, runs on deploy and on company provisioning. The only card in the plan with no user-facing surface, and correctly so</p>
<h3>Related tables</h3><ul><li>attendance_types, shifts (existing)</li></ul>
<h3>Seeder — AttendanceTypeSeeder</h3>
<p>The eleven system types, per company, with is_system = true and their fixed system_code:</p>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>system_code</th><th>Name</th><th>is_paid</th><th>counts_as_working_day</th><th>eligible_for_payroll</th></tr>
<tr><td>present</td><td>Present</td><td>true</td><td>true</td><td>true</td></tr>
<tr><td>late</td><td>Late</td><td>true</td><td>true</td><td>true</td></tr>
<tr><td>half_day</td><td>Half Day</td><td>true</td><td>true</td><td>true</td></tr>
<tr><td>absent</td><td>Absent</td><td>false</td><td>false</td><td>true</td></tr>
<tr><td>leave</td><td>Leave</td><td>true</td><td>false</td><td>true</td></tr>
<tr><td>holiday</td><td>Holiday</td><td>true</td><td>false</td><td>false</td></tr>
<tr><td>weekend</td><td>Weekend</td><td>true</td><td>false</td><td>false</td></tr>
<tr><td>wfh</td><td>Work From Home</td><td>true</td><td>true</td><td>true</td></tr>
<tr><td>business_trip</td><td>Business Trip</td><td>true</td><td>true</td><td>true</td></tr>
<tr><td>missing_check_in</td><td>Missing Check-In</td><td>false</td><td>true</td><td>true</td></tr>
<tr><td>missing_check_out</td><td>Missing Check-Out</td><td>false</td><td>true</td><td>true</td></tr>
</table>
<h3>Seeder — ShiftSeeder</h3>
<p>One default GENERAL shift: 09:00 to 18:00, 60-minute break, 8 working hours, 15-minute grace, min_hours_present 6, min_hours_half_day 3, working days Sun to Thu.</p>
<h3>Business rules</h3>
<ul>
<li>Both seeders are idempotent, keyed on (company_id, system_code) and (company_id, code) respectively, using updateOrCreate.</li>
<li>They run for every existing company on first deploy and for each new company on creation — hook into the existing company-provisioning path rather than requiring a manual command.</li>
<li>Seeded rows carry is_system = true, so the task 1.1-BE delete guard protects them.</li>
<li>Colours and icons are set to sensible defaults; HR may change them, and the engine is unaffected because it reads system_code.</li>
<li>The seeder never overwrites an HR-customised name, color or icon on re-run — only missing rows are created.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A fresh install has eleven system attendance types and one shift per company.</li>
<li>Re-running the seeders creates no duplicates and does not revert HR customisations.</li>
<li>Creating a new company provisions both sets automatically.</li>
<li>Deleting a seeded type is refused by the API (task 1.1-BE).</li>
<li>calculateDaily resolves every status it needs immediately after seeding, with no manual configuration.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Both seeders registered in DatabaseSeeder and wired into company provisioning.</li>
<li>Re-run test asserting no duplicates and preserved customisations.</li>
</ul>',
 6.00, 'todo', 'medium', @s6, NULL, NULL, @now, @now),

(@project_id, @mod_attendance,
 '9.4-FE Attendance Jobs — Frontend',
 'att-9-4-fe-attendance-jobs',
 @vo + 67, @type_fe,
 '<h3>Summary</h3><p>As HR, I want to see whether last night jobs ran, re-run them for a date range, and find the employees they skipped, without asking a developer.</p>
<p><strong>Why this task exists.</strong> Tasks 9.1-BE and 9.2-BE ship manual-trigger endpoints behind HR permissions, and 9.1-BE promises that employees skipped for having no resolved shift are surfaced in a report HR can read, not only in logs — but no screen was ever specified for either. Without this task HR must ask a developer to run a backfill, and a silently skipped employee is invisible until the month cannot be closed.</p>
<h3>Start after</h3><p>9.1-BE Nightly Close · 9.2-BE Leave Accrual</p>
<h3>Permission</h3><p><code>attendance.record-recalculate</code>, <code>attendance.leave-balance-adjust</code></p>
<h3>Menu</h3><p><strong>Attendance › Jobs</strong></p>
<h3>Related tables</h3><ul><li>attendance_records, leave_balances, leave_balance_ledger, activity_log — all existing. <strong>No new tables, no new endpoints.</strong></li></ul>
<h3>Frontend routes</h3><pre>/attendance/jobs</pre>
<h3>Main screen sections</h3>
<ul>
<li><strong>Job status panel</strong> — one row per scheduled job (Nightly close, Monthly accrual, Year-end carry-forward): last run time in company-local time, outcome, and counts (processed / created / skipped-locked / unassigned).</li>
<li><strong>Unassigned employees report</strong> — the employees the nightly close skipped because no shift resolved for the date, each one click from the Assignments screen. This is the section that earns the task.</li>
<li><strong>Manual run panel</strong> — date or period picker plus a Run button per job, with the permitted range enforced (nightly close: at most 90 days back; accrual: plus or minus 5 years).</li>
<li><strong>Batch progress</strong> — for a queued run, live progress with per-employee failures, surviving navigation.</li>
</ul>
<h3>API integration</h3>
<pre>POST /api/v1/attendance/jobs/close-day
GET  /api/v1/attendance/jobs/close-day/{batchId}
POST /api/v1/attendance/jobs/accrue-leave
POST /api/v1/attendance/jobs/carry-forward
GET  /api/v1/attendance/jobs/{batchId}</pre>
<h3>Actions</h3>
<table border="1" cellpadding="4" cellspacing="0">
<tr><th>#</th><th>Action</th><th>Visible / enabled when</th><th>Calls</th><th>Result</th><th>Main failure</th></tr>
<tr><td>1</td><td><strong>Run nightly close</strong></td><td>attendance.record-recalculate; date not after today and not before today minus 90 days</td><td>POST /jobs/close-day</td><td>Switches to progress view; status panel refreshes on completion</td><td>422 future date or over 90 days — date picker rejects before submit</td></tr>
<tr><td>2</td><td><strong>Run for one employee</strong></td><td>as #1, employee selected</td><td>POST /jobs/close-day with employee_id</td><td>Single-employee result, no batch</td><td>422 same as #1</td></tr>
<tr><td>3</td><td><strong>Accrue leave</strong></td><td>attendance.leave-balance-adjust</td><td>POST /jobs/accrue-leave</td><td>Applied count shown; re-running the same month reports <strong>0 applied</strong>, not an error</td><td>—</td></tr>
<tr><td>4</td><td><strong>Carry forward</strong></td><td>attendance.leave-balance-adjust</td><td>POST /jobs/carry-forward</td><td>Rows created for the next year</td><td>—</td></tr>
<tr><td>5</td><td>Unassigned row — <strong>Fix</strong></td><td>always</td><td>—</td><td>Navigates to Assignments (task 1.4-FE) pre-filtered to that employee</td><td>—</td></tr>
<tr><td>6</td><td><strong>Refresh status</strong></td><td>always</td><td>GET /jobs/{batchId}</td><td>Polls while a batch is running</td><td>—</td></tr>
</table>
<h3>UI rules</h3>
<ul>
<li>Times render in <strong>company-local</strong> time with the zone shown — these jobs are scheduled per company timezone, and a UTC timestamp would be actively misleading.</li>
<li>Re-running an already-applied accrual reports 0 applied as a <strong>success</strong>, not an error. The endpoints are idempotent; the UI must not make a safe action look dangerous.</li>
<li>The unassigned report is the screen primary content, not a footnote — it is the only place these employees are visible at all.</li>
<li>A job that has never run shows Never run with the scheduler expected time, so a misconfigured scheduler is visible rather than silent.</li>
<li>Manual-run controls are hidden, not disabled, for users lacking the permission.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>HR can see when each job last ran and what it did, without reading logs.</li>
<li>An employee skipped for having no shift is listed and reachable in one click.</li>
<li>HR can backfill a past date without developer help, within the permitted range.</li>
<li>Re-running an accrual for an already-accrued month reports zero applied and changes nothing.</li>
<li>Leaving the page during a queued run and returning restores the progress view.</li>
<li>A user without the job permissions sees the status panel but no run controls.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li><code>modules/attendance/api/attendanceJobApi.ts</code> with batch-status polling, reusing the shared polling hook.</li>
<li>Nav entry under Attendance.</li>
</ul>',
 6.00, 'todo', 'medium', @s8, NULL, NULL, @now, @now);

-- =====================================================================
--  PART K — EMPLOYEE MODULE ADJUSTMENTS  (3 tasks, 5 points)
--  Small tasks on the EXISTING Employee module. The original document
--  proposed rebuilding salary / tax-profile / bank-account endpoints that
--  already ship; these are the only genuine gaps.
--
--  Endpoints consumed as-is:
--    POST /api/v1/employee/salaries
--    GET  /api/v1/employee/employees/{id}/salaries
--    POST|PUT /api/v1/employee/{employeeId}/tax-profiles
--    POST|PUT /api/v1/employee/bank-accounts
--    POST /api/v1/employee/bank-accounts/{id}/set-primary
-- =====================================================================

INSERT INTO `tasks`
(`project_id`,`project_module_id`,`title`,`branch_name`,`view_order`,`type`,`description`,`estimate_hours`,`status`,`priority`,`due_date`,`qa_status`,`qa_comment`,`created_at`,`updated_at`)
VALUES
(@project_id, @mod_employee,
 '10.1-BE Salary Field Validation — Backend',
 'emp-10-1-be-salary-validation',
 @vo + 68, @type_be,
 '<h3>Summary</h3><p>As HR, I want currency and payment frequency validated against the configured allow-lists, so payroll never encounters a value it cannot process.</p>
<h3>Start after</h3><p>Nothing — this task can start immediately</p>
<h3>Permission</h3><p>Existing <code>employee.create</code> / <code>employee.update</code></p>
<h3>Menu</h3><p><strong>Employees › Salary tab</strong> — validation added to the existing form, no new screen</p>
<h3>Related tables</h3><ul><li>employee_salaries (existing, no migration)</li></ul>
<h3>Business rules</h3>
<ul>
<li>employee_salaries.currency_code must be in config(employee.salaries.currency_codes).</li>
<li>employee_salaries.payment_frequency must be in config(employee.salaries.payment_frequencies).</li>
<li>salary_structure_id must reference a structure with status = Active.</li>
<li>gross_salary must be greater than zero.</li>
</ul>
<p>Both config lists already exist in Modules/Employee/config/config.php; they are simply not enforced.</p>
<h3>Error handling</h3>
<ul>
<li>Unsupported currency or payment frequency — 422</li>
<li>Inactive salary structure — 422</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>Assigning an unsupported currency is rejected with 422.</li>
<li>Assigning an inactive salary structure is rejected with 422.</li>
<li>Existing rows with legacy values continue to read without error.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Validation added to the existing Form Request, unit-tested for each rule.</li>
</ul>',
 2.00, 'todo', 'low', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_employee,
 '10.2-BE Bank Account Payment-Method Validation — Backend',
 'emp-10-2-be-bank-account-validation',
 @vo + 69, @type_be,
 '<h3>Summary</h3><p>As Finance, I want every bank-account row to carry at least one usable payment method, so a disbursement batch never contains an unpayable item.</p>
<h3>Start after</h3><p>Nothing — this task can start immediately</p>
<h3>Permission</h3><p>Existing <code>employee.create</code> / <code>employee.update</code></p>
<h3>Menu</h3><p><strong>Employees › Bank Accounts</strong> — validation added to the existing form, no new screen</p>
<h3>Related tables</h3><ul><li>employee_bank_accounts (existing, no migration)</li></ul>
<h3>Business rules</h3>
<ul>
<li>At least one of (bank_name plus account_number) or (mobile_banking_provider plus mobile_banking_number) must be populated.</li>
<li>Only one is_primary = 1 row per employee. Setting a new primary automatically unsets the previous one — this is not an error.</li>
</ul>
<h3>Error handling</h3>
<ul>
<li>Neither payment method populated — 422</li>
<li>Setting a second primary — succeeds, unsetting the first</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>A row with neither method populated is rejected with 422.</li>
<li>Setting a second primary silently unsets the first and returns success.</li>
<li>An employee always has at most one primary account, asserted by a test.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>Primary-uniqueness enforced inside a transaction, tested under concurrent updates.</li>
</ul>',
 2.00, 'todo', 'low', @s7, NULL, NULL, @now, @now),

(@project_id, @mod_employee,
 '10.3-BE Missing Payment Method Report — Backend',
 'emp-10-3-be-missing-payment-report',
 @vo + 70, @type_be,
 '<h3>Summary</h3><p>As Finance, I want a list of employees with no primary payment method, so disbursement gaps are found before a payroll run, not during it.</p>
<h3>Start after</h3><p>10.2-BE Bank Account Validation</p>
<h3>Permission</h3><p><code>payroll.disburse</code> or <code>employee.menu-view</code></p>
<h3>Menu</h3><p>none — API only; consumed by the readiness panel in 8.3b-FE</p>
<h3>Related tables</h3><ul><li>employee_bank_accounts, employee_personal_infos (existing)</li></ul>
<h3>API endpoints</h3>
<pre>GET /api/v1/employee/employees/without-primary-payment-method?department_id=</pre>
<h3>Business rules</h3>
<ul>
<li>Returns active employees with no employee_bank_accounts row where is_primary = 1.</li>
<li>Filterable by department, branch and employment status.</li>
<li>Consumed by task 8.3b-BE disbursement-readiness rather than duplicating the query there.</li>
</ul>
<h3>Acceptance criteria</h3>
<ul>
<li>The report lists exactly the employees a disbursement would exclude.</li>
<li>Task 8.3b-BE readiness check calls this endpoint rather than reimplementing it.</li>
<li>Filtering by department narrows the result correctly.</li>
</ul>
<h3>Definition of done</h3>
<ul>
<li>Platform DoD.</li>
<li>api collection/Employee/Reports/*.yml</li>
</ul>',
 6.00, 'todo', 'low', @s7, NULL, NULL, @now, @now);

COMMIT;

-- =====================================================================
--  VERIFICATION — run after the script; expect 70 rows and 584.00 hours
--  (292 story points x 2 hours per point)
-- =====================================================================
-- SELECT COUNT(*)            AS tasks,
--        SUM(estimate_hours) AS hours,
--        MIN(view_order)     AS first_order,
--        MAX(view_order)     AS last_order
--   FROM tasks
--  WHERE project_id = @project_id
--    AND (branch_name LIKE 'att-%' OR branch_name LIKE 'pay-%'
--         OR branch_name LIKE 'core-%' OR branch_name LIKE 'emp-1%');

-- Rollback of this seed, if needed:
-- DELETE FROM tasks
--  WHERE project_id = @project_id
--    AND (branch_name LIKE 'att-%' OR branch_name LIKE 'pay-%'
--         OR branch_name LIKE 'core-0-2-%' OR branch_name LIKE 'emp-10-%');
