# Attendance & Payroll — Task Cards

**Dependency-ordered build sequence**
34 stories · 70 task cards · Date: 28 July 2026 (revised — salary advances, payment-mode split, five oversized cards broken up, per-card menu placement and action tables added)

Generated from [ATTENDANCE_PAYROLL_MODULE_SPEC.md](./ATTENDANCE_PAYROLL_MODULE_SPEC.md). Where a card and the spec disagree, the spec wins.

---

## How to read a card

Every card carries **Start after**, **Permission**, **Menu**, and **Points** in its header, and most also carry **Also needs**. Points are Fibonacci, sized against a mid-level developer already familiar with this codebase.

**Start after** vs **Also needs** — the distinction matters, because a single flat dependency list makes a card look far more blocked than it is:

| Line | Meaning | What a developer does |
|---|---|---|
| **Start after** | These must be **merged**. Without them the card's tables, services, or routes do not exist. | Wait. Nothing useful can be built yet. |
| **Also needs (can be stubbed)** | Needed at **runtime**, but a fixture, a seeded row, or a fake implementation stands in while you build. | Start now. Wire the real thing in before you open the PR. |

Every dependency carries its name, so nobody has to look up an id to know what it is. Fifty-one of the seventy cards have exactly **one** hard blocker; the most any card has is three.

**Menu** says where in the sidebar the work shows up. Backend cards have no menu of their own and name the frontend card they surface in instead. Six frontend cards are embedded panels with no nav item, one is hosted inside the Employee module's menu, and one — 9.3-BE, the seeders — has no user-facing surface anywhere. The full picture is the traceability table in the appendix.

**Actions** — eighteen cards whose screens carry a state machine, a destructive action, or a multi-step flow also have an `### Actions` table: control, when it is visible or enabled, which endpoint it calls, what the user sees, and the one failure they will actually hit. That table is the QA script; the acceptance criteria below it cover only what the table cannot express. The remaining eleven frontend cards are plain CRUD where the screen sections and UI rules are enough.

Two things are **not** repeated on every card because they apply to all of them:

- **Definition of Done** — the platform DoD is spec §0.7 (layering, Form Request + API Resource, tenancy in the repository, feature + unit tests, activity log, `api collection/*.yml`, review). Each card lists only what it adds.
- **Error contract** — spec §0.8: 409 duplicate, 404 unknown id, 403 missing permission, 422 validation, 409 state-machine violation. Cards list only story-specific errors.

Point scale: **1** trivial · **2** simple CRUD · **3** CRUD with real rules · **5** multi-entity or calculation-heavy · **8** engine work with wide blast radius.

**No card exceeds 8 points.** Five originally-larger cards were split on 28 July 2026 — 1.4, 5.3, 8.2, and 8.3 (BE and FE) — each into an `a` and a `b` half. Every split card carries a quote block naming its sibling and stating whether the halves ship independently. One pair has a hard ordering constraint: **8.2b-BE must merge before 8.3b-BE**, because 8.2a-BE writes a provisional `net_payable`.

---

## Index

| Part | Stories | Cards | Points |
|---|---|---|---|
| 0 — Bootstrap | 0.1, 0.2, 0.3 | 4 | 12 |
| A — Configuration | 1.1, 1.2, 1.3, 1.4a, 1.4b | 9 | 37 |
| B — Resolution | 2.1, 2.2 | 4 | 17 |
| C — Runtime | 3.1, 3.2 | 4 | 21 |
| D — Approval wiring | 4.1 | 2 | 8 |
| E — Business layer | 5.1, 5.2, 5.3a, 5.3b, 5.4, 5.5 | 11 | 43 |
| F — Monthly closing | 6.1, 6.2 | 4 | 18 |
| G — Payroll configuration | 7.0 – 7.6 | 14 | 56 |
| H — Payroll execution | 8.1, 8.0, 8.2a, 8.2b, 8.3a, 8.3b | 11 | 59 |
| J — Automation | 9.1, 9.2, 9.3, 9.4 | 4 | 16 |
| K — Employee module adjustments | 10.1, 10.2, 10.3 | 3 | 5 |
| | **34 + 3** | **70** | **292** |

**Split cards.** Five cards that exceeded 8 points were broken into `a` / `b` halves on 28 July 2026. Story numbering is unchanged, so the spec's §8 story mapping still holds.

| Was | Became | Seam |
|---|---|---|
| 1.4-BE (8) | 1.4a-BE (5) + 1.4b-BE (3) | single assignment · bulk assignment |
| 5.3-BE (8) | 5.3a-BE (5) + 5.3b-BE (3) | leave approval · punch-voids-leave |
| 8.2-BE (13) | 8.2a-BE (8) + 8.2b-BE (5) | payslip pipeline · advance settlement + allocation |
| 8.3-BE (13) | 8.3a-BE (5) + 8.3b-BE (8) | run approval + settlement · disbursement |
| 8.3-FE (8) | 8.3a-FE (5) + 8.3b-FE (5) | approval screen · disbursement screens |

Four of the five produce halves that ship independently. The exception is **8.2**: 8.2a-BE writes a provisional `net_payable = net_pay`, so **8.2b-BE must merge before 8.3b-BE** or a company using advances would be paid twice. That constraint is stated on both cards.

Parts A–F (Attendance) and G (Payroll) run in parallel after Part 0. They converge at Part H.

---

# Part 0 — Bootstrap

## TASK 0.1-BE: Module Scaffolding — Backend

**Task Type:** Backend · **Part:** 0 — Bootstrap · **Module:** Attendance + Payroll
**Start after:** nothing — this card can start immediately
**Permission:** n/a (infrastructure)
**Menu:** none — API only; surfaces in 0.1-FE
**Points:** 3

### Task Summary
*As a developer, I want both nwidart modules scaffolded and routed so that every later story has a place to put its code.*

### Related Tables
None.

### Commands
```bash
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
```

### Business Rules
- Each module's `RouteServiceProvider::mapApiRoutes()` sets the prefix and route-name prefix:

| Module | `prefix()` | `name()` |
|---|---|---|
| Attendance | `api/v1/attendance` | `api.attendance.` |
| Payroll | `api/v1/payroll` | `api.payroll.` |

- Mirror `Modules/Employee/app/Providers/RouteServiceProvider.php` exactly — this is the only place the URL shape is defined, and every endpoint in this document assumes it.
- Add `config/config.php` per module for tunables, modelled on `Modules/Employee/config/config.php`. Seed it with:
  - Attendance: `correction_window_days` (30), `bulk_assignment_queue_threshold` (200), `export_queue_threshold` (5000).
  - Payroll: `payslip_generation_chunk_size` (100).
- Register each module's service provider bindings file (contracts → implementations) following `EmployeeServiceProvider`.

### Acceptance Criteria
- `php artisan module:list` shows both modules enabled.
- `modules_statuses.json` contains `"Attendance": true` and `"Payroll": true`.
- A temporary `GET /api/v1/attendance/health` and `GET /api/v1/payroll/health` return 200 behind `auth:api`.
- `php artisan route:list` shows both route-name prefixes.

### Definition of Done
Platform DoD plus:
- Module config files committed with the tunables above.
- Temporary health routes retained (mirroring the Configuration module's `/health`).

---

## TASK 0.1-FE: Module Shell — Frontend

**Task Type:** Frontend · **Part:** 0 — Bootstrap · **Module:** Attendance + Payroll
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `attendance.menu-view`, `payroll.menu-view`
**Menu:** creates both nav groups — **Attendance** and **Payroll** — plus their landing pages
**Points:** 3

### Task Summary
*As a developer, I want both frontend modules registered with their navigation groups so later screens plug in without touching core code.*

### Frontend Routes
```
/attendance
/attendance/config
/payroll
```

### Main Screen Sections
- **Attendance landing** — placeholder page listing the module's sections; each entry appears only if the user holds its permission.
- **Payroll landing** — same pattern.
- **Navigation groups** — two new collapsible groups in `modules/core/config/navigation.ts`:

```
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
```

Child entries are added by their own stories; this card creates the groups and the landing pages only.

Per spec §9 R1, attendance types, shifts, policies, and assignments all live behind the **single** "Configuration" entry — one submenu, four tabs or sections, not four separate nav items. The Configuration module is not involved.

### API Integration
```
GET /api/v1/attendance/health
GET /api/v1/payroll/health
```

### UI Rules
- Routes wrapped `ProtectedRoute` → `AppLayout` → `RequirePermissionRoute`, matching `modules/employee/index.tsx`.
- A nav group renders only when the user holds its parent permission; empty groups are not shown.
- Module ids are `attendance` and `payroll`; version `1.0.0`.

### Acceptance Criteria
- Both modules are registered in `registerModules.ts` and their landing pages render for a permitted user.
- A user lacking `payroll.menu-view` sees no Payroll group and gets redirected away from `/payroll`.

### Definition of Done
Platform DoD plus:
- `modules/attendance/index.tsx` and `modules/payroll/index.tsx` created and registered.
- Nav groups added with correct permission keys.

---

## TASK 0.2-BE: Platform Prerequisites — Backend

**Task Type:** Backend · **Part:** 0 — Bootstrap · **Module:** Core
**Start after:** nothing — this card can start immediately
**Permission:** n/a (infrastructure)
**Menu:** **Admin › Companies** — one field added to the existing company form, no new screen
**Points:** 2

### Task Summary
*As the system, I need a company timezone and a working queue so attendance days and background jobs behave correctly.*

### Related Tables
- `companies` (modified)

### Related DB Schema
**companies — new column**

| Field | Type | Description |
|---|---|---|
| `timezone` | varchar(64), default `Asia/Dhaka` | IANA timezone name; the fallback for shifts that do not set their own |

### Business Rules
- All `punch_time` values are stored in UTC. `attendance_date`, shift boundaries, and the nightly job's "previous day" are resolved in the effective timezone: `shifts.timezone ?? companies.timezone`.
- Migration backfills existing rows with `Asia/Dhaka`.
- Confirm queue configuration end to end — Parts C, E, and H all dispatch jobs, following the existing `ProcessEmployeeImportJob` pattern.

### Validation Rules
- `timezone` must be a valid IANA identifier (`timezone_identifiers_list()`).

### Acceptance Criteria
- Every existing company row has a non-null timezone after migration.
- A test job dispatched to the queue is processed by the worker.
- A unit test proves a 23:30 UTC punch maps to the correct local `attendance_date` for `Asia/Dhaka`.

### Definition of Done
Platform DoD plus:
- Company update endpoint and admin UI accept the new field (small addition to the existing screen; no new screen).

---

## TASK 0.3-BE: Action & Permission Registry — Backend

**Task Type:** Backend · **Part:** 0 — Bootstrap · **Module:** Attendance + Payroll
**Start after:** 0.1-BE Module Scaffolding
**Permission:** n/a (infrastructure)
**Menu:** **Admin › Roles** — configured through the existing Actions / Modules / Roles UI, no new screen
**Points:** 4

### Task Summary
*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.*

### Related Tables
- `modules`, `actions`, `module_actions`, `permissions`, `roles`, `role_permissions`

### Seeders
- `AttendanceModuleSeeder` — creates the `attendance` module row (`route_prefix` `/attendance`, `api_prefix` `/attendance`, `sort_order` 300) and its `module_actions`.
- `PayrollModuleSeeder` — same for `payroll` (`sort_order` 400).
- `AttendancePayrollRoleSeeder` — grants the self-service subset to the seeded `employee` role.

Both module seeders call `PermissionRepositoryInterface::syncForModule($module)` at the end. Model them on `database/seeders/EmployeeModuleSeeder.php`.

### Permission Keys
Full list in spec §4. Summary:

| Module | Keys |
|---|---|
| `attendance.*` | `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` |
| `payroll.*` | `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` |

### Business Rules
- Reuse an existing `actions.slug` where one fits; create new `actions` rows only for genuinely new verbs.
- `module_actions.permission_key` is always `<module_slug>.<action_slug>`.
- 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 Story 4.1.
- 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`.
- **Segregation of duties:** no seeded role receives both `attendance.monthly-approve` and `payroll.month-freeze`.
- All seeders are idempotent (`updateOrCreate`) and safe to re-run.

### Acceptance Criteria
- After `php artisan migrate --seed`, every key in spec §4 exists in `permissions`.
- Re-running the seeders produces no duplicate rows and no changed ids.
- A user without a given key receives 403 from a route guarded by it (verified once the first guarded route exists in 1.1).
- No seeded role holds both approve and freeze permissions.

### Definition of Done
Platform DoD plus:
- Seeders registered in `DatabaseSeeder`.
- Roles configurable through the existing Admin UI (Actions → Modules → Roles) with no new screen.

---

# Part A — Configuration

## TASK 1.1-BE: Attendance Types — Backend

**Task Type:** Backend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `attendance.config-manage` (write) · `attendance.menu-view` (read)
**Menu:** none — API only; surfaces in 1.1-FE
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `attendance_types` (new)

### Related DB Schema
**attendance_types**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `name` | varchar(100) | Display name (e.g. Present, Late) |
| `code` | varchar(50) | Unique within company |
| `system_code` | varchar(50) nullable | **Immutable.** `present`, `absent`, `late`, `half_day`, `leave`, `holiday`, `weekend`, `wfh`, `business_trip`, `missing_check_in`, `missing_check_out`. NULL for HR-created types |
| `category` | varchar(50) | Grouping label for UI |
| `is_paid` | boolean | Whether this status is paid |
| `counts_as_working_day` | boolean | Working-day flag |
| `eligible_for_payroll` | boolean | Included in payroll calculation |
| `color` | varchar(30) | UI colour |
| `icon` | varchar(50) | UI icon |
| `is_system` | boolean, default false | Seeded row; blocks delete |
| `status` | varchar(20), default `Active` | Active / Inactive |
| `created_by` / `updated_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, code)` · `unique(company_id, system_code)` · `index(company_id, status)`

### API Endpoints
```
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}
```

### Business Rules
- HR can create attendance types with name, code, category, the three flags, colour, and icon.
- `name` and `code` are each unique within a company.
- `system_code` is set only by the seeder (Story 9.3) and can never be changed through the API. HR may rename, recolour, or deactivate a system type but not re-map it.
- The calculation engine (Story 3.2) resolves statuses by `system_code`, never by `name` or `id`.
- Rows with `is_system = true` cannot be deleted, only deactivated.
- A type referenced by any `attendance_records` row cannot be deleted.
- Inactive types are not selectable for new records but remain visible in historical data and reports.

### Validation Rules
- `name`, `code` — required, unique per `company_id`.
- `code` — uppercase alphanumeric plus underscore, max 50.
- `system_code` — rejected outright if present in a create or update request body.
- `is_paid`, `counts_as_working_day`, `eligible_for_payroll` — required booleans.
- `color` — valid hex or Bootstrap variant name.

### Error Handling
- Delete on `is_system = true` → **409**, message names the constraint.
- Delete on a referenced type → **409**, response body includes the referencing record count.
- `system_code` present in request body → **422**.

### Acceptance Criteria
- HR can create a new attendance type with all mandatory fields validated.
- Duplicate name or code within the same company is rejected with 409.
- A seeded system type cannot be deleted, and its `system_code` is unchanged after an update request that tries to include one.
- Deactivating a type removes it from selectable options everywhere but leaves historical records readable.
- A type used by at least one attendance record cannot be deleted.
- Queries return only the caller's company rows, verified by a cross-tenant test.

### Definition of Done
Platform DoD plus:
- Migration with all three keys.
- Unit tests covering `system_code` immutability and both delete-guard paths.
- `api collection/Attendance/Attendance Types/*.yml`.

---

## TASK 1.1-FE: Attendance Types — Frontend

**Task Type:** Frontend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 1.1-BE Attendance Types · 0.1-FE Module Shell
**Permission:** `attendance.config-manage` (write) · `attendance.menu-view` (read)
**Menu:** **Attendance › Configuration › Attendance Types**
**Points:** 3

### Task Summary
*As HR, I want a single screen to manage Attendance Types with a visual preview of each type's colour and icon, so setup mistakes are obvious before they reach attendance records.*

### Related Tables
- `attendance_types` — see 1.1-BE for the schema.

### Frontend Routes
```
/attendance/config/attendance-types
```

### Main Screen Sections
- **Attendance Type List** — colour/icon preview chip per row, status badge, "System" tag on `is_system` rows. Filterable by status and category.
- **Add / Edit modal** — name, code, category, colour picker, icon picker, three boolean toggles. On a system type, `code` is read-only and delete is absent.
- **Status toggle** — inline Active/Inactive switch, optimistic update with rollback on error.
- **Delete confirmation** — shows the referencing-record count from the 409 body instead of a generic failure.

### API Integration
```
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}
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Add type** | `attendance.config-manage` | `POST /attendance-types` | Row appears in list with its colour chip | 409 duplicate name or code → field error |
| 2 | **Edit** | `config-manage` | `PUT /attendance-types/{id}` | Row updates in place | On a system type, `code` is read-only with a tooltip |
| 3 | **Toggle status** | `config-manage` | `PATCH /{id}/status` | Optimistic switch, rolls back on error | — |
| 4 | **Delete** | `config-manage` **and** `is_system = false` | `DELETE /{id}` | Row removed | 409 referenced → dialog shows the referencing record count |
| 5 | Colour / icon pick | in the form | — | Live preview matching the list chip | — |

**Not offered:** Delete on a system type — the button is **absent**, not disabled, because a seeded type can never be deleted and a disabled control invites repeated attempts.

### UI Rules
- Delete button hidden entirely on `is_system` rows — not shown-and-disabled.
- `code` disabled in edit mode for system types, with a tooltip explaining why.
- Colour and icon render live in the form as the user picks them, matching the list chip.
- Users without `attendance.config-manage` see a read-only list; mutating controls are not rendered.
- A 409 on delete surfaces the server message inline in the confirmation dialog, not as a toast.

### Acceptance Criteria
- HR can create, edit, activate, and deactivate types without leaving the screen.
- A system type visibly cannot be deleted or re-coded.
- Deleting a referenced type shows how many records reference it.
- A read-only user sees the list and no mutating controls.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/attendanceTypeApi.ts` with TanStack Query hooks.
- Nav entry under Attendance → Configuration.

---

## TASK 1.2-BE: Shift Management — Backend

**Task Type:** Backend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.2-BE Platform Prerequisites · 0.3-BE Permission Registry
**Permission:** `attendance.config-manage` (write) · `attendance.menu-view` (read)
**Menu:** none — API only; surfaces in 1.2-FE
**Points:** 5

### Task Summary
*As HR, I want to define working shifts so check-in/out and status calculation follow the correct schedule, including overnight shifts.*

### Related Tables
- `shifts` (new)

### Related DB Schema
**shifts**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `name` | varchar(150) | Shift name |
| `code` | varchar(50) | Unique within company |
| `start_time` / `end_time` | time | Shift schedule |
| `timezone` | varchar(64) nullable | IANA name; falls back to `companies.timezone` |
| `break_minutes` | int, default 0 | Unpaid break duration |
| `working_hours` | decimal(5,2) | Expected paid hours |
| `grace_minutes` | int, default 0 | Late-arrival grace period |
| `min_hours_present` | decimal(5,2) | Minimum hours for Present |
| `min_hours_half_day` | decimal(5,2) | Minimum hours for Half Day |
| `working_days` | json | ISO-8601 day numbers, 1 = Monday, e.g. `[7,1,2,3,4]` |
| `is_overnight` | boolean, default false | Crosses midnight |
| `status` | varchar(20), default `Active` | Active / Inactive |
| `created_by` / `updated_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, code)` · `index(company_id, status)`

### API Endpoints
```
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}
```

### Business Rules
- HR defines name, code, start/end time, break, grace period, working days, overnight flag, and the two minimum-hours thresholds.
- `name` and `code` are each unique within a company.
- Deactivated shifts remain visible for historical reporting but cannot be newly assigned (enforced in Story 1.4).
- A shift referenced by an active assignment cannot be deleted; a shift referenced by any `attendance_records` row cannot be deleted at all.
- `timezone` left null means the company timezone applies. This is the value snapshotted into `policy_snapshot` (Story 2.2).

### Validation Rules
- `end_time > start_time` unless `is_overnight = true`.
- `min_hours_half_day ≤ min_hours_present ≤ working_hours`.
- `working_days` — non-empty array of unique integers 1–7.
- `working_hours` — greater than 0 and at most 24.
- `grace_minutes`, `break_minutes` — non-negative integers.
- `timezone` — valid IANA identifier when present.

### Error Handling
- `end_time ≤ start_time` without the overnight flag → **422** with a message naming both fields.
- Delete on a shift with an active assignment → **409**, response lists the assignment count.

### Acceptance Criteria
- HR can create a shift with valid start/end times, including an overnight shift flagged correctly.
- The system rejects an end time that is not after the start time unless the shift is marked overnight.
- Threshold ordering is enforced — a shift with `min_hours_present` above `working_hours` is rejected.
- Deactivated shifts remain readable in historical reports but are absent from assignment pickers.
- A shift in use cannot be deleted.

### Definition of Done
Platform DoD plus:
- Unit tests for overnight validation and threshold ordering.
- `api collection/Attendance/Shifts/*.yml`.

---

## TASK 1.2-FE: Shift Management — Frontend

**Task Type:** Frontend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 1.2-BE Shifts
**Permission:** `attendance.config-manage` (write) · `attendance.menu-view` (read)
**Menu:** **Attendance › Configuration › Shifts**
**Points:** 3

### Task Summary
*As HR, I want to build and review shifts on one screen, with the working-week and overnight behaviour visible at a glance.*

### Related Tables
- `shifts` — see 1.2-BE for the schema.

### Frontend Routes
```
/attendance/config/shifts
```

### Main Screen Sections
- **Shift List** — start/end time, a compact working-days summary (`Sun–Thu`), an overnight badge, and status.
- **Add / Edit form** — time pickers, working-days checkbox row, break/grace/threshold numeric fields, overnight toggle, optional timezone select.
- **Status toggle** — inline Active/Inactive.
- **Delete action** — blocked with an explanatory dialog when the shift is assigned.

### API Integration
```
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}
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Add shift** | `attendance.config-manage` | `POST /shifts` | Row appears with working-days summary | 422 threshold ordering → inline errors before submit |
| 2 | Pick end time earlier than start | in the form | — | **Overnight toggle flips automatically** with a visible hint; user may override | — |
| 3 | **Edit** | `config-manage` | `PUT /shifts/{id}` | Row updates | 422 `end_time` not after `start_time` without the overnight flag |
| 4 | **Toggle status** | `config-manage` | `PATCH /{id}/status` | Inline switch | — |
| 5 | **Delete** | `config-manage` | `DELETE /{id}` | Row removed | 409 assigned → dialog lists the assignment count |
| 6 | Change times / break | in the form | — | Live "expected span minus break" line so an inconsistent `working_hours` is visible | — |

### UI Rules
- Overnight toggle flips automatically (with a visible hint) when the user picks an end time earlier than the start time; the user can override.
- A live "expected span" line under the time pickers shows the computed duration minus break, so an inconsistent `working_hours` is obvious before saving.
- Threshold fields show inline errors as soon as the ordering rule breaks, without waiting for submit.
- Working-days row renders in the company's week order, not always Monday-first.
- Timezone select defaults to "Use company timezone" rather than pre-filling a value.

### Acceptance Criteria
- HR can create an overnight shift and see it badged as such in the list.
- Invalid threshold ordering is surfaced before submit.
- Attempting to delete an assigned shift explains why it is blocked.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/shiftApi.ts`.
- Nav entry under Attendance → Configuration.

---

## TASK 1.3-BE: Policy Configuration (Leave & Holiday) — Backend

**Task Type:** Backend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `attendance.config-manage` (write) · `attendance.menu-view` (read)
**Menu:** none — API only; surfaces in 1.3-FE
**Points:** 5

### Task Summary
*As HR, I want to configure Leave and Holiday policies through one consistent model so both share the same setup, status, and assignment behaviour.*

### Related Tables
- `policies` (new)

### Related DB Schema
**policies**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `policy_type` | enum(`leave`,`holiday`) | Which family of rules `config` holds |
| `name` | varchar(150) | Policy name |
| `code` | varchar(50) | Unique within company **per policy_type** |
| `effective_date` | date | Policy start date |
| `config` | json | Type-specific rules; shapes below |
| `status` | enum(`Active`,`Inactive`,`Archived`) | Lifecycle |
| `created_by` / `updated_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, policy_type, code)` · `index(company_id, policy_type, status)`

**`config` when `policy_type = leave`**
```json
{
  "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
}
```

**`config` when `policy_type = holiday`**
```json
{
  "holidays": [
    { "name": "Victory Day", "date": "2026-12-16", "type": "public", "recurring": true, "description": "" }
  ]
}
```

### API Endpoints
```
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}
```

### Business Rules
- A policy is created with `policy_type`, name, code, effective date, status, and a `config` object matching its type.
- `config` is validated against the type-specific schema — an unknown key is rejected rather than silently stored.
- `name` and `code` are each unique within a company **per policy_type**, so a leave policy and a holiday policy may share a code.
- Only `Active` policies are selectable during assignment (Story 1.4).
- `Inactive` policies remain readable for historical reporting; `Archived` policies are hidden from all pickers and default list views.
- A policy referenced by an assignment, a leave request, or a leave balance cannot be deleted — deactivate or archive instead.
- Recurring holidays repeat on the same month/day each year; the resolver (Story 2.1) expands them for the requested year.

### Validation Rules
- `policy_type`, `name`, `code`, `effective_date`, `config` — required.
- Leave `config`: `entitlement_days > 0`; `max_carry_forward` required and `> 0` when `carry_forward_allowed = true`; `advance_notice_days`, `backdate_limit_days` non-negative; `accrual_method` in the enum.
- Holiday `config`: `holidays` non-empty; each entry needs `name` and a valid `date`; no two entries share the same date within one policy.

### Error Handling
- Duplicate code within `(company, policy_type)` → **409**.
- `config` shape not matching `policy_type` → **422**, errors keyed by `config.<field>`.
- Delete on a referenced policy → **409**, response names the referencing entity type and count.

### Acceptance Criteria
- HR can create a Leave policy and a Holiday policy through the same endpoint with type-specific validation applied to each.
- Duplicate codes within the same type and company are rejected; the same code in the other type is accepted.
- A leave policy with `carry_forward_allowed` but no `max_carry_forward` is rejected with a field-level error.
- Only Active policies appear in assignment pickers; Inactive ones remain in historical reports.
- A policy in use cannot be deleted.

### Definition of Done
Platform DoD plus:
- `config` validated by dedicated rule objects per policy type, unit-tested for both shapes.
- `api collection/Attendance/Policies/*.yml`.

---

## TASK 1.3-FE: Policy Configuration (Leave & Holiday) — Frontend

**Task Type:** Frontend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 1.3-BE Policies
**Permission:** `attendance.config-manage` (write) · `attendance.menu-view` (read)
**Menu:** **Attendance › Configuration › Policies**
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `policies` — see 1.3-BE for the schema.

### Frontend Routes
```
/attendance/config/policies
/attendance/config/policies/{id}
```

### Main Screen Sections
- **Policy List** — tabs or a filter for Leave / Holiday, showing name, code, effective date, and status.
- **Add / Edit form** — shared header fields, then a type-driven body: the leave rule set, or the holiday list builder.
- **Holiday list builder** — repeatable rows (name, date, type, recurring, description) with add/remove and date-sorted display; shown only for holiday policies.
- **Leave rule panel** — accrual method, entitlement, carry-forward group, encashment, half-day, LWP, notice/backdate limits, document requirement.
- **Status actions** — Activate / Deactivate / Archive.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Add policy** | `attendance.config-manage` | `POST /policies` | Row in the matching type tab | 409 duplicate code within the same `policy_type` |
| 2 | Pick `policy_type` | **create only** | — | Form body swaps between leave rules and the holiday builder | Locked after creation — switching would invalidate the whole `config` |
| 3 | Toggle `carry_forward_allowed` | leave policies | — | `max_carry_forward` appears and becomes required | 422 if left empty |
| 4 | **Add holiday row** | holiday policies | — | New repeatable row | Duplicate date blocked client-side, conflicting row highlighted |
| 5 | **Save** | `config-manage` | `PUT /policies/{id}` | Policy updated | 422 errors map back to the offending repeatable row, not the form root |
| 6 | **Activate / Deactivate** | `config-manage` | `PATCH /{id}/status` | Status badge changes | — |
| 7 | **Archive** | `config-manage` | `PATCH /{id}/status` | Disappears from default list and all pickers | Confirmation states this explicitly |

### UI Rules
- `policy_type` is chosen once at creation and locked thereafter — switching type would invalidate the whole `config`.
- `max_carry_forward` is hidden until `carry_forward_allowed` is on, and required once visible.
- The holiday builder blocks a duplicate date client-side and highlights the conflicting row.
- Recurring holidays show a "repeats yearly" marker so a one-off is visibly different.
- Archive is separated from Deactivate in the UI with a confirmation explaining that archived policies disappear from all pickers.
- Field-level errors from the 422 body map back onto the correct repeatable row, not to the form root.

### Acceptance Criteria
- HR creates both policy types from one screen with the correct fields appearing per type.
- Duplicate holiday dates are caught before submit.
- Carry-forward cap cannot be left empty once carry-forward is enabled.
- Archived policies disappear from the default list and from assignment pickers.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/policyApi.ts`.
- Nav entry under Attendance → Configuration.

---

## TASK 1.4a-BE: Shift & Policy Assignment — Backend

**Task Type:** Backend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 1.2-BE Shifts · 1.3-BE Policies
**Also needs (can be stubbed):** 1.1-BE Attendance Types
**Permission:** `attendance.assignment-manage` (write) · `attendance.menu-view` (read)
**Menu:** none — API only; surfaces in 1.4-FE
**Points:** 5

> Split from the original 8-point 1.4-BE. This card owns the table and single-target assignment; **1.4b-BE** adds bulk assignment. Both halves ship independently — HR can assign one at a time before bulk exists.

### Task Summary
*As HR, I want to assign shifts, leave policies, and holiday policies to an employee or to an organisation unit, with full history preserved.*

### Related Tables
- `assignments` (new)

### Related DB Schema
**assignments**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `assignable_type` | enum(`shift`,`policy`) | What is being assigned |
| `assignable_id` | unsignedBigInteger | `shifts.id` or `policies.id` |
| `assignable_subtype` | varchar(20) nullable | `leave` / `holiday` when assigning a policy; denormalised so overlap checks can tell leave from holiday |
| `scope_type` | enum(`company`,`branch`,`division`,`department`,`team`,`employee`) | Which level this applies to |
| `scope_id` | unsignedBigInteger nullable | PK of the matching table; NULL when `scope_type = company`. For `employee`, **`employee_personal_infos.id`** |
| `effective_date` | date | When the assignment starts |
| `end_date` | date nullable | When it ends, if applicable |
| `status` | varchar(20), default `Active` | Active / Inactive |
| `created_by` | unsignedBigInteger nullable | Who made the assignment |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, scope_type, scope_id)` · `index(company_id, assignable_type, effective_date)`

Scope tables: `branch` → `branches`, `division` → `divisions`, `department` → `departments`, `team` → `teams`, `employee` → `employee_personal_infos`. There is no `org_units` table (spec §0.2).

### API Endpoints
```
GET    /api/v1/attendance/assignments
GET    /api/v1/attendance/assignments?scope_type=&scope_id=&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
```

`POST /assignments/bulk` belongs to 1.4b-BE.

### Business Rules
- 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.
- `effective_date` is required. `end_date` is optional and must not precede it.
- **Overlap rule:** for a given `(company_id, scope_type, scope_id, assignable_type, assignable_subtype)`, no two `Active` rows may have overlapping `[effective_date, end_date]` ranges. An open-ended row overlaps everything after its start.
- The overlap check runs inside a transaction with `SELECT … FOR UPDATE` on the scope's existing rows. A database constraint cannot express this. **It is exposed as a single service method so 1.4b-BE can reuse it per row rather than reimplementing it.**
- Only `Active` shifts and policies are assignable.
- 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.
- Every write emits `AssignmentChanged` so the resolver cache (Story 2.1) can invalidate.

### Validation Rules
- `assignable_type`, `assignable_id`, `scope_type`, `effective_date` — required.
- `assignable_subtype` — required when `assignable_type = policy`, and must match the referenced policy's `policy_type`.
- `scope_id` — required unless `scope_type = company`; must exist and be same-company.
- `end_date ≥ effective_date` when present.
- Referenced shift/policy must have `status = Active`.

### Error Handling
- Overlapping active assignment → **409**, response includes the conflicting assignment's id and date range.
- Assigning an Inactive shift or policy → **422**.
- `scope_id` not found in the table implied by `scope_type` → **404**.

### Acceptance Criteria
- HR can assign a shift to one employee or to an entire department in a single action.
- A second active shift assignment overlapping an existing one for the same scope is blocked with the conflicting range shown.
- A leave-policy assignment and a holiday-policy assignment to the same scope and dates both succeed — they do not conflict with each other.
- A future-dated assignment is accepted and takes effect on its effective date without further action.
- Complete assignment history remains queryable after a new assignment supersedes an old one.

### Definition of Done
Platform DoD plus:
- Migration for `assignments`.
- Overlap detection unit-tested against open-ended ranges, adjacent ranges, and identical ranges.
- Scope resolution unit-tested for all six `scope_type` values.
- The overlap check extracted as one reusable service method, consumed by 1.4b-BE.
- `api collection/Attendance/Assignments/*.yml`.

---

## TASK 1.4b-BE: Bulk Assignment — Backend

**Task Type:** Backend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 1.4a-BE Assignments
**Permission:** `attendance.assignment-manage`
**Menu:** none — API only; surfaces in 1.4-FE, Bulk Assign section
**Points:** 3

> Split from the original 8-point 1.4-BE. Reuses 1.4a-BE's overlap check per row; adds nothing to the schema.

### Task Summary
*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.*

### Related Tables
- `assignments` — see 1.4a-BE. **No new tables, no new columns.**

### API Endpoints
```
POST /api/v1/attendance/assignments/bulk
GET  /api/v1/attendance/assignments/bulk/{batchId}
```

### Business Rules
- Bulk assignment takes an org-unit filter plus an optional employee multi-select, and creates one `assignments` row per resolved employee through 1.4a-BE's service — the overlap rule is not reimplemented here.
- **Per-row isolation:** 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.
- Batches above `attendance.bulk_assignment_queue_threshold` (default 200) run as a **queued job** returning `202 Accepted` with a batch id and a status endpoint, mirroring `ProcessEmployeeImportJob`.
- Batches at or below the threshold run synchronously and return the result table directly.
- The status endpoint reports progress (`total`, `processed`, `succeeded`, `failed`) and, once complete, the per-employee reason for each failure.
- Batch results are retained long enough for HR to read them after navigating away — at least 24 hours.
- Every successful row emits `AssignmentChanged`, exactly as the single-assignment path does.

### Calculation Pseudocode
```
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
```

### Validation Rules
- One of `filter` or `employee_ids` is required; both together is allowed and unions the two sets.
- `assignable_type`, `assignable_id`, `effective_date` — required, validated exactly as in 1.4a-BE.
- A resolved set of zero employees → **422**, rather than a batch that silently does nothing.

### Error Handling
- Batch above the threshold → **202 Accepted** with a batch id (not an error).
- Empty resolved employee set → **422**.
- A per-employee overlap conflict → recorded in `failed` with the conflicting assignment's date range; **the batch continues**.
- Unknown `batchId` → **404**.

### Acceptance Criteria
- A bulk assign over 200 employees returns a batch id and completes in the background with a per-row result report.
- 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.
- A batch of 50 returns its result synchronously without a batch id.
- The status endpoint reports progress while the job is still running.
- Re-reading the batch status after 12 hours still returns the result table.
- An empty filter result is rejected rather than reported as a successful batch of zero.

### Definition of Done
Platform DoD plus:
- Queued job with a status endpoint, modelled on `ProcessEmployeeImportJob`.
- Partial-failure test: assert succeeded rows persist when a sibling row fails.
- Threshold boundary tested at exactly 200 and at 201.
- `api collection/Attendance/Assignments/bulk-*.yml`.

---

## TASK 1.4-FE: Shift & Policy Assignment — Frontend

**Task Type:** Frontend · **Part:** A — Configuration · **Module:** Attendance
**Start after:** 1.4a-BE Assignments
**Also needs (can be stubbed):** 1.4b-BE Bulk Assignment
**Permission:** `attendance.assignment-manage` (write) · `attendance.menu-view` (read)
**Menu:** **Attendance › Configuration › Assignments**
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `assignments` — see 1.4a-BE for the schema.

### Frontend Routes
```
/attendance/config/assignments
/attendance/config/assignments/{id}
```

### Main Screen Sections
- **Assignment List** — filters for assignable type, scope level, org unit, and employee; columns show what is assigned, to whom, and the effective range.
- **Add Assignment form** — assignable picker (shift / leave policy / holiday policy), scope-level select that swaps the second picker between company, branch, division, department, team, and employee.
- **Bulk Assign screen** — org-unit filter, resulting employee list with multi-select, then a per-row result table after submission.
- **End Assignment action** — sets `end_date` via a date picker.
- **Assignment History timeline** — per employee or org unit, showing superseded rows greyed with their date ranges.

### API Integration
```
GET    /api/v1/attendance/assignments
GET    /api/v1/attendance/assignments?scope_type=&scope_id=&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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Pick scope level | in the form | — | Second picker swaps between company / branch / division / department / team / employee | — |
| 2 | **Assign** | `attendance.assignment-manage` | `POST /assignments` | Row appears in list | 409 overlap → **inline conflict card** with the existing range and a link to end it |
| 3 | **Bulk assign** | `assignment-manage` | `POST /assignments/bulk` | ≤200: result table returned inline; >200: progress view with a batch id | 422 empty resolved set → stated before anything is created |
| 4 | Leave and return during a bulk run | batch running | `GET /assignments/bulk/{batchId}` | Progress view restored | — |
| 5 | **End assignment** | `assignment-manage` | `POST /{id}/end` | `end_date` set; row moves to history | 422 `end_date` before `effective_date` |
| 6 | **Edit** | `assignment-manage` | `PATCH /{id}` | Only end date and status editable; scope and assignable are read-only | — |
| 7 | View **history** | always | `GET /assignments?scope_type=&scope_id=&history=true` | Timeline with the currently effective row marked distinctly from past and future-dated | — |

### UI Rules
- 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.
- Assignable pickers list only Active shifts and policies; inactive ones are absent, not disabled.
- A 409 overlap renders as an inline conflict card showing the existing assignment's range with a link to end it, not as a toast.
- Bulk submission above the threshold switches to a progress view polling the batch status; the user can leave and return.
- The bulk result table separates succeeded and failed rows, with the failure reason per employee and a copy-to-clipboard action.
- The history timeline marks the currently effective row distinctly from past and future-dated rows.
- Scope and assignable fields are read-only in edit mode — only the end date and status are editable.

### Acceptance Criteria
- HR assigns a shift to a whole department in one action and sees a per-employee result.
- An overlapping assignment shows which existing assignment conflicts and offers to end it.
- A future-dated assignment is visibly distinguished from the currently effective one.
- Leaving the page during a bulk run and returning restores the progress view.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/assignmentApi.ts` with batch-status polling.
- Nav entry under Attendance → Configuration.

---

# Part B — Assignment & Calculation Foundation

## TASK 2.1-BE: Assignment Resolution Service — Backend

**Task Type:** Backend · **Part:** B — Resolution · **Module:** Attendance
**Start after:** 1.4a-BE Assignments
**Permission:** `attendance.assignment-preview` (diagnostic endpoint)
**Menu:** none — API only; surfaces in 2.1-FE
**Points:** 8

### Task Summary
*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.*

### Related Tables
- `assignments` — existing from 1.4; no new columns.
- `employee_organization_assignments` — read-only, from the Employee module.

### API Endpoints
```
GET /api/v1/attendance/resolve/assignment?employee_id=&date=
```

### Business Rules
- **Most-specific-wins precedence**, evaluated independently per assignable type:
  `employee > team > department > division > branch > company`
- Org-unit membership is read **as of the requested date**, 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.
- Resolution respects `effective_date` / `end_date` ranges and is deterministic for a given `(employee_id, date)`.
- A date with no active shift is returned as an explicit unassigned result — **never silently defaulted**.
- Results are cached per `(company_id, employee_id, date)` and invalidated on `AssignmentChanged`, `HolidayPolicyUpdated`, and `EmployeeTransferred`.
- `EmployeeTransferred` does not yet exist — **this story adds it** to `EmployeeOrganizationAssignmentService::transfer()` in the Employee module.
- The Attendance Calculation Engine (3.2), leave validation (5.2), and payroll (8.2) call this service and never query `assignments` directly.

### Calculation Pseudocode
```
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
    }
```

### Validation Rules
- `employee_id` and `date` — required; employee must exist and belong to the caller's company.

### Error Handling
- No active shift for the date → **422** with `code: unassigned` and the reason.
- Unknown `employee_id` → **404**.

### Acceptance Criteria
- Given overlapping employee-level and department-level assignments, the employee-level one wins.
- An employee transferred from Department A to Department B on the 15th resolves A's shift for the 10th and B's shift for the 20th.
- A date in an unassigned gap returns 422 `unassigned`, not a default shift.
- Cache is invalidated by all three events; a resolve immediately after an assignment change returns the new result.
- Calculation engine, leave validation, and payroll receive identical context for the same input, verified by an integration test.
- Recurring holidays are expanded correctly for the requested year, including a leap-year date.

### Definition of Done
Platform DoD plus:
- Precedence and date-range edge cases covered by unit tests, including adjacent and open-ended ranges.
- Cache invalidation verified against all three triggering events.
- `EmployeeTransferred` event added to the Employee module and dispatched on transfer.
- `api collection/Attendance/Resolution/*.yml`.

---

## TASK 2.1-FE: Assignment Resolution Preview — Frontend

**Task Type:** Frontend · **Part:** B — Resolution · **Module:** Attendance
**Start after:** 2.1-BE Assignment Resolver
**Permission:** `attendance.assignment-preview`
**Menu:** **Attendance › Configuration › Assignments** — linked from that screen, deliberately **not** its own nav item
**Points:** 2

### Task Summary
*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.*

### Related Tables
- `assignments` — see 1.4a-BE for the schema.

### Frontend Routes
```
/attendance/config/assignment-preview
```

### Main Screen Sections
- **Preview tool** — employee picker plus date picker, resolving on change.
- **Resolved result panel** — shift, leave policies, holiday calendar, timezone; each row annotated with the scope level it resolved from (e.g. "Shift — from Department").
- **Unassigned warning state** — a distinct, prominent panel when the API returns 422 `unassigned`.

### API Integration
```
GET /api/v1/attendance/resolve/assignment?employee_id=&date=
```

### UI Rules
- The screen is reachable only by holders of `attendance.assignment-preview`; it is not in the primary nav, but linked from the Assignments screen.
- The "resolved from" annotation is always visible — the point of the tool is explaining *why*, not just *what*.
- An unassigned result renders as a warning card with a link to create the missing assignment, never as an empty panel.
- Changing either input re-resolves without a submit button.

### Acceptance Criteria
- HR can look up the applicable shift, policies, and calendar for any employee on any date.
- Each resolved value shows which scope level produced it.
- An unassigned gap is clearly surfaced with a route to fix it.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/resolutionApi.ts`.
- Linked from the Assignments screen.

---

## TASK 2.2-BE: Policy Snapshotting — Backend

**Task Type:** Backend · **Part:** B — Resolution · **Module:** Attendance
**Start after:** 2.1-BE Assignment Resolver
**Permission:** `attendance.record-recalculate`
**Menu:** none — API only; surfaces in 2.2-FE
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `attendance_records` (column added here; the table itself is created in 3.2-BE)

### Related DB Schema
**attendance_records — column**

| Field | Type | Description |
|---|---|---|
| `policy_snapshot` | json | The resolved shift/policy values used for this record's calculation |

Snapshot contents:
```json
{
  "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"
}
```

### API Endpoints
```
POST /api/v1/attendance/attendance-records/{id}/recalculate
     body: { "use_current_policy": false }
```

### Business Rules
- The calculation engine embeds resolved policy **values**, not just ids, into `policy_snapshot`.
- Recalculation uses the stored snapshot by default. Re-resolving live policy happens only when `use_current_policy = true`, which requires `attendance.record-recalculate`.
- No calculated record is ever written without a snapshot.
- Recalculation is blocked on records with `is_locked = true` regardless of permission.
- 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.

### Validation Rules
- `use_current_policy` — required boolean.

### Error Handling
- Recalculate on a locked record → **409**.
- `use_current_policy = true` without the permission → **403**.

### Acceptance Criteria
- Changing a shift's grace period today does not change the late/on-time status of a record calculated last month.
- The default recalculation path reads the stored snapshot, proven by a test that mutates the shift between calculation and recalculation.
- `use_current_policy = true` re-applies the new rules and is audit-logged with before/after values.
- No row exists in `attendance_records` with a null `policy_snapshot` after 3.2 ships.

### Definition of Done
Platform DoD plus:
- Default and override recalculation paths separately unit-tested.
- Audit-log entry asserted for the override path.

---

## TASK 2.2-FE: Applied Policy Panel — Frontend

**Task Type:** Frontend · **Part:** B — Resolution · **Module:** Attendance
**Start after:** 2.2-BE Policy Snapshotting · 3.2-FE Daily Summary
**Permission:** `attendance.record-recalculate`
**Menu:** **Attendance › Attendance Records** — embedded panel on the record detail (3.2-FE), no own nav item
**Points:** 2

### Task Summary
*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.*

### Related Tables
- `attendance_records` — see 2.2-BE and 3.2-BE.

### Frontend Routes
```
(surfaces inside the Attendance Record detail view from 3.2-FE)
```

### Main Screen Sections
- **"Policy applied" panel** — 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.
- **Recalculate action** — a split control offering "Recalculate" (snapshot) and "Recalculate with current policy" (override), the second gated on permission and behind a confirmation dialog.

### API Integration
```
POST /api/v1/attendance/attendance-records/{id}/recalculate
```

### UI Rules
- The override option carries an explicit warning that it can change historical numbers, and requires a typed confirmation, not a single click.
- Both actions are hidden on locked records, with a lock badge explaining why.
- After recalculation the panel refreshes in place and highlights any value that changed.

### Acceptance Criteria
- HR can see the exact policy values behind any historical day's status.
- The override action cannot be triggered accidentally.
- Locked records show no recalculate action at all.

### Definition of Done
Platform DoD plus:
- Panel and split action added to the record detail view.

---

# Part C — Runtime

## TASK 3.1-BE: Multi-Punch Check-In / Check-Out — Backend

**Task Type:** Backend · **Part:** C — Runtime · **Module:** Attendance
**Start after:** 2.1-BE Assignment Resolver
**Permission:** `attendance.punch-create` · `attendance.punch-create-others` (on behalf of)
**Menu:** none — API only; surfaces in 3.1-FE
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `attendance_punches` (new)

### Related DB Schema
**attendance_punches**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `attendance_date` | date | Logical attendance day; handles overnight shifts |
| `punch_type` | enum(`in`,`out`) | Direction |
| `punch_time` | datetime | Server time, stored UTC |
| `source` | enum(`web`,`mobile`,`biometric`,`api`,`manual`) | Origin |
| `ip_address` | varchar(45) nullable | IP at time of punch |
| `device_info` | varchar(255) nullable | Device/browser info |
| `remarks` | varchar(255) nullable | Optional note |
| `sequence_no` | int | Order of punch within the day |
| `superseded_by_id` | bigint nullable | Set when a correction replaces this punch |
| `correction_request_id` | bigint nullable | Set on punches created by an approved correction |
| `created_at` | timestamp | Record creation time |

Keys: `index(company_id, employee_id, attendance_date)` · `index(superseded_by_id)`

**Append-only.** No `updated_at`, no update endpoint, no delete endpoint.

### API Endpoints
```
POST /api/v1/attendance/punch
GET  /api/v1/attendance/punches?date=&employee_id=
```

### Business Rules
- An employee may punch in and out multiple times within the same `attendance_date`.
- 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.
- `punch_time` is always server time. A client-supplied timestamp is ignored unless `source = manual` **and** the caller holds `attendance.punch-create-others`.
- `attendance_date` is resolved through the effective shift and timezone (Story 2.1). For an overnight shift, a 01:00 punch belongs to the previous day's `attendance_date`.
- Punching on a date that resolves as unassigned is rejected with `code: unassigned`.
- Every punch records source, IP, device info, and optional remarks.
- `sequence_no` is `lastNonSuperseded.sequence_no + 1`, starting at 1.
- Corrections never edit a punch. They insert new rows and stamp `superseded_by_id` on the ones they replace (Story 5.5). All calculation queries filter `whereNull('superseded_by_id')`.
- On an `out` punch, emit `PunchCreated`, which queues the day's recalculation (Story 3.2).
- Employees may punch only for themselves unless they hold `attendance.punch-create-others`.

### Calculation Pseudocode
```
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 '" + punch_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
```

### Validation Rules
- `punch_type` — required, in the enum.
- `source` — required, in the enum; only `manual` is accepted from a privileged caller with an explicit `punch_time`.
- `remarks` — max 255.
- `employee_id` — accepted only from a caller holding `attendance.punch-create-others`; otherwise derived from the authenticated user.

### Error Handling
- Two consecutive same-type punches → **422** naming the last punch's time.
- Unassigned date → **422** with `code: unassigned`.
- `employee_id` supplied without `punch-create-others` → **403**.

### Acceptance Criteria
- An employee can check in, check out for lunch, check in again, and check out for the day — four separate ordered punches.
- A second consecutive check-in without an intervening check-out is rejected, and vice versa.
- Each punch stores its own source, time, and IP independently.
- An overnight-shift employee's 01:00 punch is attributed to the previous day's `attendance_date`.
- A superseded punch is excluded from the consecutive-type check and from all calculations.
- No endpoint exists that can modify or delete a punch.

### Definition of Done
Platform DoD plus:
- Overnight `attendance_date` resolution unit-tested across a DST-free and a DST-observing timezone.
- Supersession filtering asserted in the consecutive-type check.
- `api collection/Attendance/Punches/*.yml`.

---

## TASK 3.1-FE: Multi-Punch Check-In / Check-Out — Frontend

**Task Type:** Frontend · **Part:** C — Runtime · **Module:** Attendance
**Start after:** 3.1-BE Punches
**Permission:** `attendance.punch-create`
**Menu:** **Attendance › Punch** — plus a dashboard widget
**Points:** 3

### Task Summary
*As an employee, I want a single obvious control to check in and out, and a clear view of today's punches.*

### Related Tables
- `attendance_punches` — see 3.1-BE for the schema.

### Frontend Routes
```
/attendance/punch
```
Plus a compact punch widget on the dashboard.

### Main Screen Sections
- **Punch button** — one toggle reading "Check In" or "Check Out" based on the last non-superseded punch of the day.
- **Today's punch timeline** — chronological list with source icons, times in the employee's shift timezone, and a superseded marker where applicable.
- **Remarks field** — optional, shown at the moment of punching.
- **Dashboard widget** — same button plus today's total hours so far.

### API Integration
```
POST /api/v1/attendance/punch
GET  /api/v1/attendance/punches?date=today
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Check In / Check Out** | `attendance.punch-create`; label derived from the last non-superseded punch **on the server**, not local state | `POST /punch` | Timeline gains a row; button flips | 422 consecutive same type → re-reads state rather than assuming |
| 2 | Same, while a request is in flight | — | — | Button **disabled** until the response lands | This is what prevents a double-click creating a rejected punch |
| 3 | Add **remarks** | at the moment of punching | included in `POST /punch` | Stored with the punch | — |
| 4 | Open the screen with no shift assigned | — | `POST` never fires | The button is **replaced** by an explanatory panel, not shown with an error | 422 `unassigned` handled before render |
| 5 | Dashboard widget punch | anywhere in the app | `POST /punch` | Same as #1, plus today's running total | — |

**Not offered:** editing or deleting a punch. The table is append-only; corrections go through 5.4-FE.

### UI Rules
- The button's label and colour derive from server state, not local state — after a failed request it re-reads rather than assuming.
- The button is disabled while a punch is in flight, to prevent a double-submit creating a rejected consecutive punch.
- 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.
- 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.
- Superseded punches remain visible, struck through, with a link to the correction that replaced them.

### Acceptance Criteria
- The button always reflects the correct next action after a page refresh.
- Double-clicking cannot produce two punches.
- An employee with no shift assigned sees a clear explanation, not an error toast.
- Today's timeline matches the server exactly, including superseded entries.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/punchApi.ts`.
- Dashboard widget registered; nav entry under Attendance.

---

## TASK 3.2-BE: Daily Attendance Summary — Backend

**Task Type:** Backend · **Part:** C — Runtime · **Module:** Attendance
**Start after:** 3.1-BE Punches · 2.2-BE Policy Snapshotting
**Permission:** `attendance.record-view-own` / `-team` / `-all` · `attendance.record-export`
**Menu:** none — API only; surfaces in 3.2-FE
**Points:** 8

### Task Summary
*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.*

### Related Tables
- `attendance_records` (new)

### Related DB Schema
**attendance_records**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `attendance_date` | date | Which day |
| `shift_id` | unsignedBigInteger nullable | Shift applicable that day |
| `attendance_type_id` | unsignedBigInteger | Calculated status |
| `first_check_in` | datetime nullable | Day's first in-punch |
| `last_check_out` | datetime nullable | Day's last out-punch |
| `total_working_hours` | decimal(6,2), default 0 | Sum of paired in/out durations |
| `overtime_hours` | decimal(6,2), default 0 | Auto-calculated |
| `late_minutes` | int, default 0 | Minutes past grace period |
| `early_leave_minutes` | int nullable | Minutes left early |
| `punch_count` | int, default 0 | Non-superseded punches that day |
| `policy_snapshot` | json | Story 2.2; never null on a calculated row |
| `is_locked` | boolean, default false | True once the month is approved |
| `calculated_at` | timestamp nullable | Last calculation time |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, employee_id, attendance_date)` · `index(company_id, attendance_date)`

### API Endpoints
```
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
```

### Business Rules
- The summary pairs non-superseded punches for each `attendance_date` to produce first check-in, last check-out, total working hours, and punch count.
- Status is resolved by `system_code` on `attendance_types` — never by name or id.
- Recalculation runs automatically after every check-out (`PunchCreated`) and after any approved correction or leave.
- A locked record is never recalculated by any automatic path.
- **Punch voids leave** (spec §9 R2): 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 referencing the voided day, in the same transaction as the record write.
- Voiding is per **date**. Other days of the same multi-day leave request are unaffected, and `leave_requests` itself is never mutated — the effective consumed figure is `sum(day_value)` over active rows.
- A locked day never voids a leave, because it is never recalculated.
- **Visibility:** `record-view-own` → self only; `record-view-team` → employees within the caller's reporting scope via `employee_reporting_managers`; `record-view-all` → the whole company.
- Exports above `attendance.export_queue_threshold` (default 5000 rows) run as a queued job returning a download token.
- Emits `AttendanceCalculated` on every write, and `LeaveDayVoided` when a leave day is voided.

### Calculation Pseudocode
```
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 -> 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 >= shift.min_hours_present and late_minutes == 0 ? present :
        working_hours >= shift.min_hours_present                       ? late :
        working_hours >= shift.min_hours_half_day                      ? half_day :
                                                                         absent

    # --- punch voids leave ---
    if leave_day:
        if leave_day.day_value == 1.00 or working_hours >= 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
```

Three rules matter here and are the reason this differs from a naive implementation:

1. Holiday and weekend checks run **before** "no punches ⇒ absent". Otherwise every weekend is recorded as Absent and the monthly totals are wrong.
2. `missing_check_out` is evaluated **first**, not last. Otherwise an employee who worked eight hours and forgot to check out is recorded as Present with a null checkout.
3. **A punch beats an approved leave** — company rule, spec §9 R2. The day is calculated from the punches and the leave day is voided and refunded. Returning LEAVE before ever looking at the punches means an employee who came in anyway loses the day's balance *and* is recorded as not having worked.

**Half-day carve-out.** A half-day leave is voided only when the punches show a full day's work (`working_hours >= min_hours_present`). Voiding on any punch would make half-day leave unusable, since the employee always punches for the half they work. Open for HR confirmation; reverting is one condition.

**Split of delivery.** `leave_request_days` and `LeaveService::voidLeaveDay()` do not exist yet at Part C — leave lands in Part E. This card therefore implements everything except the two `leave_day` branches, behind a `LeaveDayResolver` interface with a null implementation. **Story 5.3b-BE supplies the real implementation and the tests for those branches.** The pseudocode is given here in full because it is the canonical contract for the day calculation, and splitting it across two cards would hide the ordering rules that make it correct.

### Validation Rules
- Query filters (`employee_id`, org unit, date range, status) validated; date range capped at 366 days per request.

### Error Handling
- Requesting another employee's record without the required scope permission → **403**.
- Export request over the threshold → **202** with a download token.

### Acceptance Criteria
- After an employee's final check-out, the summary shows correct total working hours and status.
- A weekend with no punches is recorded as Weekend, not Absent.
- An eight-hour day with no check-out is recorded as Missing Check-Out, not Present.
- An approved correction triggers recalculation of the affected day.
- Locked summaries are not altered by any automatic recalculation.
- `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 Story 5.3b-BE.
- An employee with only `record-view-own` cannot read a colleague's record.
- A manager with `record-view-team` sees exactly their reporting scope, no more.
- Every written record has a non-null `policy_snapshot`.

### Definition of Done
Platform DoD plus:
- Every branch of the status ladder unit-tested, including the two ordering rules above.
- Session-pairing tested with an unmatched trailing `in` and with three in/out pairs.
- Visibility scoping tested for all three permission levels.
- Queued export with a download token.
- `api collection/Attendance/Attendance Records/*.yml`.

---

## TASK 3.2-FE: Daily Attendance Summary — Frontend

**Task Type:** Frontend · **Part:** C — Runtime · **Module:** Attendance
**Start after:** 3.2-BE Daily Summary
**Permission:** `attendance.record-view-own` / `-team` / `-all` · `attendance.record-export`
**Menu:** **Attendance › Attendance Records** — plus a Today card on the dashboard
**Points:** 5

### Task Summary
*As an employee I want to see my own day at a glance, and as HR I want a filterable grid of everyone's daily attendance with an export.*

### Related Tables
- `attendance_records` — see 3.2-BE for the schema.

### Frontend Routes
```
/attendance/attendance-records
/attendance/attendance-records/{id}
```

### Main Screen Sections
- **Today's Attendance card** — self-service: status chip, hours so far, punch count; visible to every employee.
- **Daily Summary grid** — HR view with filters for employee, org unit, date range, and status; columns for status, first in, last out, hours, overtime, late minutes.
- **Export button** — xlsx/csv; switches to a progress state and a download link when the job is queued.
- **Record detail view** — the day's numbers, the underlying punch timeline (from 3.1), and the applied-policy panel (from 2.2-FE).

### API Integration
```
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
```

### UI Rules
- Status chips use each attendance type's configured colour and icon from Story 1.1, so configuration is visibly connected to output.
- Late minutes and early-leave minutes render only when non-zero, to keep the grid scannable.
- A `missing_check_out` row is visually distinct from `absent` — they mean different things to HR.
- 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.
- The grid's employee and org-unit filters are hidden entirely for a user with only `record-view-own`.
- A locked record shows a lock badge in both the grid and the detail view.
- Export over the threshold shows a progress indicator and a persistent download link, surviving navigation.

### Acceptance Criteria
- An employee sees their own day without any filter controls.
- HR can filter by department and date range and export the result.
- A missing-check-out day is immediately distinguishable from an absent day.
- The detail view shows punches and applied policy on one screen.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/attendanceRecordApi.ts` with export polling.
- Today's card registered on the dashboard; nav entry under Attendance.

---

# Part D — Approval Engine Integration

## TASK 4.1-BE: Approval Engine Integration — Backend

**Task Type:** Backend · **Part:** D — Approval wiring · **Module:** Attendance + Payroll
**Start after:** 0.3-BE Permission Registry
**Permission:** platform admin (existing approval-settings permissions)
**Menu:** none — API only; surfaces in 4.1-FE
**Points:** 5

### Task Summary
*As the system, I want Correction, Leave, Monthly Attendance, Payroll run, and Salary advance approvals routed through the existing platform approval engine, because a mature versioned workflow engine already exists and a second one must not be built.*

### Related Tables
All existing. **No new tables, no new columns.**

`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`.

### Related DB Schema
**approval_requests (existing — the real shape)**

| Field | Type | Description |
|---|---|---|
| `id` / `uuid` | bigint PK / uuid | Identity |
| `company_id` | FK companies | Multi-tenant scope |
| `module_id` / `module_action_id` | FK | Which action is being approved |
| `workflow_version_id` | FK | Pinned workflow version |
| `requester_id` | FK users | Submitter |
| `status` | enum | `pending`, `approved`, `rejected`, `cancelled`, `failed`, `executed` |
| `title` | string | Human-readable summary |
| `correlation_id` | varchar(100) nullable | **`unique(company_id, correlation_id)`** — link back to the business record |
| `submitted_at` / `completed_at` / `executed_at` | timestamp | Lifecycle |

Step tracking lives in `approval_request_steps` / `approval_request_approvers`; history in `approval_audits`; the pending change body in `approval_payloads`. **Do not add `approvable_type`, `approval_flow_id`, `current_step`, or a `history` JSON column anywhere.**

### API Endpoints
No new endpoints. Setup uses the existing platform routes:
```
POST /api/v1/module-actions        (one-time: register the 5 approvable actions)
POST /api/v1/approval-settings     (per company: wire each action to a workflow)
```
Approve/reject at runtime use the platform's existing `approval-requests` endpoints. **No Attendance or Payroll module adds its own approve/reject route.**

### Business Rules
- Five distinct `module_actions`, so each can carry an independent workflow:

| Module | Action slug | Permission key | Entity type (executor key) |
|---|---|---|---|
| `attendance` | `correction-approve` | `attendance.correction-approve` | `attendance_correction` |
| `attendance` | `leave-approve` | `attendance.leave-approve` | `leave_request` |
| `attendance` | `monthly-approve` | `attendance.monthly-approve` | `monthly_attendance` |
| `payroll` | `run-approve` | `payroll.run-approve` | `payroll_run` |
| `payroll` | `advance-approve` | `payroll.advance-approve` | `salary_advance` |

- Submission always goes through `App\Platform\Services\ApprovalGateway::submit()` with an `ApprovalSubmissionData` DTO. Modules never write to `approval_requests` directly.
- `correlation_id` is **prefixed**, 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}`
- When `approval_settings.approval_enabled = 0` for an action, the gateway executes the `onApproved` closure synchronously and writes no approval row. **This is the documented bypass, not an error.**
- Each entity type gets one `ApprovalExecutorInterface` implementation registered in `ApprovalExecutorRegistry` from the module service provider. The executor is the **only** place a business record transitions to its approved state, so the bypass path and the workflow path share identical logic.
- Business records keep their own `status` column for querying and display. Step and approver history is read from the platform's approval endpoints via `correlation_id` — never duplicated onto business tables.

### Reference Implementation
```php
$result = $this->approvalGateway->submit(new ApprovalSubmissionData(
    companyId:     $companyId,
    requesterId:   $userId,
    moduleSlug:    'attendance',
    actionSlug:    'leave-approve',
    operation:     ApprovalOperation::Custom,
    entityType:    'leave_request',
    entityId:      (string) $leaveRequest->id,
    payloadBefore: null,
    payloadAfter:  $leaveRequest->toApprovalPayload(),
    title:         "Leave request — {$employeeName}, {$startDate} to {$endDate}",
    correlationId: "leave_request:{$leaveRequest->id}",
    onApproved:    fn () => $this->leaveApprovalService->apply($leaveRequest),
));
```

Registration, in the module service provider:
```php
$this->app->make(ApprovalExecutorRegistryContract::class)
    ->register('leave_request', LeaveRequestExecutor::class);
```

Model the executor on `Modules\Employee\Approval\EmployeeBankAccountExecutor`.

### Executor Responsibilities
| Entity type | On approval the executor does |
|---|---|
| `attendance_correction` | insert superseding punches, recalculate the day, mark the request approved |
| `leave_request` | re-validate balance, deduct with a ledger row, stamp leave on working days, mark approved |
| `monthly_attendance` | set month approved, lock the month's records, set `ready_for_payroll` |
| `payroll_run` | set run approved, finalize its payslips, settle netted salary advances |
| `salary_advance` | set the advance approved and stamp `approved_by` — it does **not** pay the money; HR records the handover separately |

This card creates the five executor **stubs** with their interface and registration. Each executor's body is implemented by its own story (5.5, 5.3, 6.1, 8.3, 7.5).

`salary_advance` has a second bypass in front of the platform one: when `payroll_settings.advance_requires_approval = false`, Story 7.5 approves the advance without calling the gateway at all. The executor is still the only place the record transitions, so both paths call it.

### Validation Rules
- An action must have an `approval_settings` row before any request against it is submitted; if none exists, treat as `approval_enabled = 0`.
- An enabled action with no configured workflow → the gateway raises a validation error.

### Error Handling
- Enabled action with no workflow → **422**.
- Unknown `moduleSlug`/`actionSlug` pair → **422** from the gateway.
- Duplicate `correlation_id` within a company → **409**, meaning a second request for the same record is already pending.

### Acceptance Criteria
- All five module actions are registered and independently configurable with their own workflow.
- A submitted request appears in `approval_requests` with the prefixed `correlation_id` and the correct `module_action_id`.
- With `approval_enabled = 0`, the action executes immediately and no `approval_requests` row is written.
- With `approval_enabled = 1`, the record stays pending until the workflow completes, then the executor runs exactly once.
- A leave request with id 5 and a correction request with id 5 in the same company can both be pending simultaneously.
- `php artisan migrate:status` shows no new approval-related migration from this story.

### Definition of Done
Platform DoD plus:
- Five executors registered, each with a passing stub test asserting registry resolution.
- `approval_settings` configured for all five actions in the demo company via a seeder, following `ConfigurationApprovalSeeder`.
- Integration test covering both the bypass and the workflow path for one action.

---

## TASK 4.1-FE: Approval Settings Exposure — Frontend

**Task Type:** Frontend · **Part:** D — Approval wiring · **Module:** Platform (existing screens)
**Start after:** 4.1-BE Approval Integration
**Permission:** existing platform admin permissions
**Menu:** **Admin › Approval Settings** — the existing platform screen, extended not rebuilt
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `approval_settings`, `approval_workflows` — existing.

### Frontend Routes
```
/admin/approval-settings        (existing screen — extended, not rebuilt)
```

### Main Screen Sections
- **Existing Approval Settings screen** — the five new module actions appear in the existing module-action picker once seeded.
- **Existing Workflow Builder** — used as-is to design each action's step sequence.
- **Pending-approval indicators** — small additions to the Attendance and Payroll list screens showing a "Pending approval" badge derived from the business record's own status.

### API Integration
```
GET  /api/v1/module-actions
POST /api/v1/approval-settings
```

### UI Rules
- 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.
- Where a module screen shows an approval state, it reads the business record's `status` field; it does not query the approval tables directly.
- 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.

### Acceptance Criteria
- An administrator can find and configure a workflow for all five new actions in the existing screen.
- Attendance and Payroll list screens show a pending-approval badge on records awaiting a decision.
- No new admin screen was added.

### Definition of Done
Platform DoD plus:
- Shared `<ApprovalStatusBadge />` component in `modules/core/components` for reuse by 5.x, 6.x, and 8.x.

---

# Part E — Business Layer

## TASK 5.1-BE: Leave Balance Management — Backend

**Task Type:** Backend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 1.3-BE Policies
**Also needs (can be stubbed):** 1.4a-BE Assignments
**Permission:** `attendance.leave-balance-view` · `attendance.leave-balance-adjust`
**Menu:** none — API only; surfaces in 5.1-FE
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `leave_balances` (new)
- `leave_balance_ledger` (new)

### Related DB Schema
**leave_balances**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `leave_policy_id` | unsignedBigInteger | `policies.id` where `policy_type = leave` |
| `year` | smallint | Balance year |
| `entitled_days` | decimal(6,2), default 0 | Total entitlement accrued so far |
| `used_days` | decimal(6,2), default 0 | Consumed via approved leave |
| `carried_forward_days` | decimal(6,2), default 0 | Carried from the previous year |
| `encashed_days` | decimal(6,2), default 0 | Encashed amount |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, employee_id, leave_policy_id, year)`

**Available balance is derived, never stored:**
`available = entitled_days + carried_forward_days − used_days − encashed_days`

**leave_balance_ledger**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `leave_balance_id` | unsignedBigInteger | Parent balance row |
| `entry_type` | enum(`accrual`,`carry_forward`,`consumption`,`reversal`,`encashment`,`manual_adjustment`) | What happened |
| `days` | decimal(6,2) | Signed — negative for consumption |
| `reference_type` / `reference_id` | varchar(50) nullable / unsignedBigInteger nullable | e.g. `leave_request` / id |
| `reason` | varchar(255) nullable | Mandatory for `manual_adjustment` |
| `created_by` | unsignedBigInteger nullable | Actor |
| `created_at` | timestamp | When |

Keys: `index(leave_balance_id, created_at)`

### API Endpoints
```
GET   /api/v1/attendance/leave-balances?employee_id=&year=&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
```

### Business Rules
- A balance row is created per employee, per assigned leave policy, per year. Creation is triggered by a listener on leave-policy assignment (Story 1.4) and by the accrual job (Story 9.2).
- **Every mutation to `leave_balances` writes a `leave_balance_ledger` row in the same transaction.** The sum of ledger rows must equal the balance columns — this is assertable and is tested.
- `used_days` increases when a leave request is approved (Story 5.3) and decreases via a `reversal` entry when an approved leave is cancelled.
- Carry-forward at year end follows the policy's rule and cap (Story 9.2).
- `encashed_days` is tracked separately and reduces available balance.
- Manual adjustment requires `attendance.leave-balance-adjust` and a mandatory reason, and writes a `manual_adjustment` ledger row.
- An employee may always read their own balances; reading another employee's requires `attendance.leave-balance-view`.

### Calculation Pseudocode
```
function adjust(balance_id, days, reason, actor):
    balance = LeaveBalance.lockForUpdate(balance_id)

    transaction:
        balance.entitled_days += days           # signed
        assert balance.available >= 0 or allow_negative
        balance.save()
        Ledger.create({ balance_id, entry_type: 'manual_adjustment',
                        days, reason, created_by: actor })
```

### Validation Rules
- `days` — required, non-zero decimal.
- `reason` — required, max 255, for manual adjustment.
- Adjustment that would drive `available` below zero is rejected unless an explicit `allow_negative` flag is passed.

### Error Handling
- Adjustment without a reason → **422**.
- Adjustment driving available below zero without `allow_negative` → **422** stating the resulting figure.
- Reading another employee's balance without permission → **403**.

### Acceptance Criteria
- A new employee's balance row is created automatically once a leave policy is assigned to them.
- Approving a leave request reduces the correct balance row with a `consumption` ledger entry, without manual intervention.
- Cancelling an approved leave writes a `reversal` entry and restores the balance.
- Manual adjustment without a reason is rejected.
- 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.
- An employee can read their own balance without `leave-balance-view`.

### Definition of Done
Platform DoD plus:
- Ledger-to-balance reconciliation test.
- Row-level locking used on every balance mutation, tested under a concurrent-approval scenario.
- `api collection/Attendance/Leave Balances/*.yml`.

---

## TASK 5.1-FE: Leave Balance Management — Frontend

**Task Type:** Frontend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.1-BE Leave Balances
**Permission:** `attendance.leave-balance-view` · `attendance.leave-balance-adjust`
**Menu:** **Attendance › Leave › Balances** — plus a My Balances dashboard card
**Points:** 3

### Task Summary
*As HR, I want a balance grid with a readable ledger behind each figure, so I can answer "why is this number what it is" without a database query.*

### Related Tables
- `leave_balances`, `leave_balance_ledger` — see 5.1-BE.

### Frontend Routes
```
/attendance/leave/balances
/attendance/leave/balances/{employeeId}
```

### Main Screen Sections
- **Balance grid** — employee × policy for the selected year, showing entitled, carried forward, used, encashed, and the derived available.
- **Ledger view** — chronological entries behind one balance row, with entry type, signed days, reference link, actor, and reason.
- **Manual adjustment form** — signed day count and a mandatory reason, with a live preview of the resulting available balance.
- **My balances card** — self-service summary for the logged-in employee.

### API Integration
```
GET   /api/v1/attendance/leave-balances?employee_id=&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
```

### UI Rules
- Available balance is computed and labelled as derived, so nobody mistakes it for an editable field.
- The adjustment form shows before → after figures before submission; a negative result is blocked unless the user explicitly ticks "allow negative".
- Ledger entries link to their source: a `consumption` row links to the leave request, a `carry_forward` row to the previous year's balance.
- Reference and reason columns are never truncated without a hover expansion — they are the audit trail.
- Users without `leave-balance-adjust` see the ledger but no adjustment control.

### Acceptance Criteria
- HR can open any balance and read every entry that produced it.
- An adjustment shows its effect before it is applied.
- An employee sees only their own balances when they lack `leave-balance-view`.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/leaveBalanceApi.ts`.
- My-balances card registered on the dashboard; nav entry under Attendance → Leave.

---

## TASK 5.2-BE: Leave Application — Backend

**Task Type:** Backend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.1-BE Leave Balances · 2.1-BE Assignment Resolver · 4.1-BE Approval Integration
**Permission:** `attendance.leave-apply`
**Menu:** none — API only; surfaces in 5.2-FE
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `leave_requests` (new)

### Related DB Schema
**leave_requests**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `leave_policy_id` | unsignedBigInteger | `policies.id` where `policy_type = leave` |
| `start_date` / `end_date` | date | Leave period |
| `duration_type` | enum(`full_day`,`half_day_first`,`half_day_second`) | Full or half day |
| `total_days` | decimal(5,2) | Auto-calculated working days |
| `reason` | text | Mandatory justification |
| `attachment_path` | varchar(255) nullable | Supporting document |
| `status` | enum(`pending`,`approved`,`rejected`,`cancelled`) | Lifecycle |
| `decided_by` / `decided_at` | unsignedBigInteger nullable / timestamp nullable | Decision metadata |
| `decision_reason` | text nullable | Mandatory on reject |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, employee_id, start_date)` · `index(company_id, status)`

### API Endpoints
```
POST  /api/v1/attendance/leave-requests
GET   /api/v1/attendance/leave-requests?employee_id=me&status=
GET   /api/v1/attendance/leave-requests/{id}
PATCH /api/v1/attendance/leave-requests/{id}/cancel
POST  /api/v1/attendance/leave-requests/preview
```

`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.

### Business Rules
- An employee selects a leave policy **from those assigned to them** (resolved via Story 2.1), a date range, and a duration type.
- `total_days` counts **working days only** — 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.
- 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.
- On submit, the request is routed through `ApprovalGateway::submit()` with `actionSlug: 'leave-approve'` and `correlationId: "leave_request:{id}"` (Story 4.1).
- The employee may view status and cancel while the request is `pending`; cancelling also cancels the approval request.

### Calculation Pseudocode
```
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 < 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 () => LeaveApprovalService.apply(request)))

    return request
```

### Validation Rules
- `leave_policy_id`, `start_date`, `end_date`, `duration_type`, `reason` — required.
- `end_date ≥ start_date`.
- `duration_type` other than `full_day` requires `start_date == end_date` and the policy's `half_day_allowed = true`.
- `attachment_path` — pdf/jpg/png, max 5 MB, matching the Employee module's document rules.

### Error Handling
- Policy not assigned to the employee → **422**.
- Insufficient balance → **422** stating available and requested days.
- Overlapping request → **409** naming the conflicting request's dates.
- Range containing no working days → **422**.
- Outside notice or backdate limits → **422** naming the limit.

### Acceptance Criteria
- An employee can submit a leave request only against a policy currently assigned to them.
- A Thursday-to-Sunday request in a Sun–Thu working week counts only the working days, not four.
- Preview shows the same `total_days` the submission produces.
- An over-limit request is blocked when the policy disallows LWP, and permitted when it allows it.
- Overlapping requests are blocked with the conflicting dates named.
- The employee can cancel their own pending request, and the linked approval request is cancelled with it.
- A half-day request spanning two dates is rejected.

### Definition of Done
Platform DoD plus:
- Working-day counting unit-tested against holidays, weekends, and a half-day.
- Preview and submit share one calculation path, asserted by a test.
- `api collection/Attendance/Leave Requests/*.yml`.

---

## TASK 5.2-FE: Leave Application — Frontend

**Task Type:** Frontend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.2-BE Leave Application
**Permission:** `attendance.leave-apply`
**Menu:** **Attendance › Leave › Requests**
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `leave_requests` — see 5.2-BE.

### Frontend Routes
```
/attendance/leave/requests
/attendance/leave/requests/{id}
/attendance/leave/requests/new
```

### Main Screen Sections
- **Apply Leave form** — policy dropdown (assigned policies only), date range picker, duration type, live "N working days" figure, live balance before/after, reason, attachment upload.
- **My Leave Requests list** — status badges, date range, day count, policy.
- **Detail view** — submitted values, approval progress (via the shared badge from 4.1-FE), and a Cancel button while pending.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Pick policy | `attendance.leave-apply` | — | Only policies **assigned to this employee** are listed | — |
| 2 | Pick date range | policy selected | `POST /leave-requests/preview` (debounced) | Live working-day count and before/after balance; non-working days greyed in the picker | — |
| 3 | Range spanning >1 date | — | — | Duration type collapses to full-day | — |
| 4 | Range exceeding balance | — | — | LWP-allowed policy → **warning, still submittable**; otherwise blocked | — |
| 5 | **Attach document** | range > `document_required_after_days` | multipart with the submit | Required, with the reason stated | 422 wrong type or >5 MB |
| 6 | **Submit** | preview returned a non-zero day count | `POST /leave-requests` | Status `pending`; approval trail visible | 409 overlap → conflicting dates named; 422 outside notice/backdate limits |
| 7 | **Cancel** | status = `pending` | `PATCH /{id}/cancel` | Request cancelled **and** the linked approval request withdrawn — stated in the confirmation | — |

### UI Rules
- The working-day count comes from `preview`, never from client-side date maths — the client does not know the employee's holiday calendar.
- Non-working days and holidays are visibly greyed in the date picker once a policy is selected.
- The balance panel shows available → after-this-request, and turns into a warning (not a hard block) when the policy allows LWP and the request exceeds the balance.
- Duration type collapses to full-day when the range spans more than one date.
- The attachment field becomes required, with an explanation, once the selected range exceeds `document_required_after_days`.
- Cancel is offered only while pending, and warns that it also withdraws the approval request.

### Acceptance Criteria
- The day count in the form matches what the server records.
- A range containing only weekends is rejected before submission with a clear message.
- An LWP-eligible over-limit request warns but submits; a non-LWP one is blocked.
- The attachment requirement appears automatically for long requests.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/leaveRequestApi.ts` with debounced preview.
- Nav entry under Attendance → Leave.

---

## TASK 5.3a-BE: Leave Approval — Backend

**Task Type:** Backend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.2-BE Leave Application · 4.1-BE Approval Integration
**Permission:** `attendance.leave-approve`
**Menu:** none — API only; surfaces in 5.3-FE
**Points:** 5

> Split from the original 8-point 5.3-BE. This card owns the approval path and the per-day rows; **5.3b-BE** wires those rows into the punch-voids-leave rule (spec §9 R2). Leave approval is fully usable before 5.3b lands.

### Task Summary
*As HR, I want to review and decide leave applications so balances and attendance records stay synchronised in one atomic action.*

### Related Tables
- `leave_request_days` (new)
- `leave_requests`, `leave_balances`, `leave_balance_ledger`, `attendance_records`, `approval_requests` (existing by now)

### Related DB Schema
**leave_request_days** — new; also required by the punch-voids-leave rule in 5.3b-BE

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `leave_request_id` | unsignedBigInteger | Parent request |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `leave_date` | date | One row per counted working day |
| `day_value` | decimal(3,2) | `1.00` or `0.50` |
| `status` | enum(`active`,`voided`), default `active` | Whether this day is still consumed |
| `voided_reason` | varchar(100) nullable | Written by 5.3b-BE; `punched` when voided by attendance |
| `voided_at` | timestamp nullable | When voided |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, employee_id, leave_date, leave_request_id)` · `index(leave_request_id, status)`

A leave request spans a range, but voiding operates on a **single date**. 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 card even though only 5.3b-BE and the cancel path write them.

### API Endpoints
```
GET /api/v1/attendance/leave-requests?status=pending&employee_id=&policy_id=&from=&to=
GET /api/v1/attendance/leave-requests/{id}
```

Approve and reject use the **platform's existing** endpoints:
```
POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject
GET  /api/v1/approval-requests/{id}/history
```
**This module adds no approve or reject route.**

### Business Rules
- HR can view, search, and filter leave requests by employee, policy, status, and date range, within their visibility scope.
- The decision is made through the approval engine. `LeaveRequestExecutor` (stub created in 4.1) is implemented here and is the only place a leave request becomes approved.
- The executor **re-validates the balance at execution time** — it may have changed between submission and approval — and fails the request with a clear reason rather than driving the balance negative.
- 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 **working days only**, using the same holiday and shift filter as Story 5.2. `sum(day_value)` must equal `leave_requests.total_days`.
- On rejection a reason is mandatory and no balance, day row, or attendance row changes.
- 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. **This is the first consumer of `voidLeaveDay()`, which 5.3b-BE generalises.**
- Emits `LeaveDecided`.

### Calculation Pseudocode
```
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 < 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)
```

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.

### Validation Rules
- Rejection requires `decision_reason`, max 500.
- Approval is blocked when the dates now overlap another approved leave created since submission.

### Error Handling
- Balance became insufficient since submission → **422** stating both figures.
- Request already finalised → **409**.
- Rejection without a reason → **422**.
- Approving a leave whose dates fall in a locked month → **409**.

### Acceptance Criteria
- Approving a leave request updates the balance and the affected days' attendance in the same transaction; a failure in either rolls back both.
- Approved leave is stamped only on working days — a Thursday-to-Sunday approval in a Sun–Thu week touches the working days only.
- HR cannot reject without entering a reason.
- Approval is blocked when the balance has since become insufficient, with the current figure shown.
- Cancelling an approved leave restores the balance, voids its remaining day rows, and recalculates the affected days.
- The leave queue respects the caller's visibility scope.
- `sum(day_value)` over a request's day rows equals its `total_days` on approval.

### Definition of Done
Platform DoD plus:
- Migration for `leave_request_days`.
- `LeaveRequestExecutor` implemented, registered, and tested through both the bypass and workflow paths.
- `LeaveService::voidLeaveDay(day, reason)` implemented for the cancel path — 5.3b-BE reuses it unchanged.
- Transaction rollback tested by forcing a failure during attendance stamping.
- Concurrency test: two approvers acting on requests that together exceed the balance — exactly one succeeds.

---

## TASK 5.3b-BE: Punch Voids Leave — Backend

**Task Type:** Backend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.3a-BE Leave Approval · 3.2-BE Daily Summary
**Permission:** `attendance.leave-approve` (no new permission)
**Menu:** none — no endpoints at all; surfaces as the voided-day marker in 3.2-FE and the per-day status in 5.3-FE
**Points:** 3

> Split from the original 8-point 5.3-BE. This card closes the loop opened in 3.2-BE, which shipped `LeaveDayResolver` as a null implementation. Nothing here changes the schema.

### Task Summary
*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.*

### Related Tables
- `leave_request_days`, `leave_balances`, `leave_balance_ledger`, `attendance_records` — all existing. **No new tables, no new columns.**

### API Endpoints
None. This card changes calculation behaviour only; it is observable through the existing attendance-record and leave-balance endpoints.

### Business Rules
- **Decision R2 (spec §9):** 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.
- Replaces the null `LeaveDayResolver` from 3.2-BE with the real implementation, reading `active` rows from `leave_request_days` for `(employee, date)`.
- Enables the two `leave_day` branches in `calculateDaily` that 3.2-BE left dormant, including the half-day carve-out.
- Reuses `LeaveService::voidLeaveDay(day, reason)` from 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's transaction.
- Voiding is per **date**, not per request. Other days of the same multi-day leave are unaffected, and `leave_requests` is never mutated. The effective consumed figure is `sum(day_value)` over `active` rows; the request keeps its original shape for audit.
- A locked day never voids a leave, because a locked record is never recalculated.
- Emits `LeaveDayVoided`.

### Calculation Pseudocode
The two branches enabled in `calculateDaily` (full contract in 3.2-BE):

```
leave_day = LeaveDayResolver.activeLeaveDay(employee_id, date)      # was null before this card

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

# … punches exist → status computed from attendance, as in 3.2-BE …

if leave_day:
    if leave_day.day_value == 1.00 or working_hours >= 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
```

**Half-day carve-out.** A half-day leave is voided only when the punches show a full day's 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 (spec §9, open sub-question under R2).

### Validation Rules
None — no new input surface.

### Error Handling
- A voiding failure rolls back the whole `calculateDaily` transaction, so a record is never written with an unrefunded leave day.

### Acceptance Criteria
- 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.
- Voiding day 3 of a five-day leave leaves days 1, 2, 4, and 5 active, and `leave_requests.total_days` unchanged at 5.
- A half-day leave plus a partial day of punches yields Half Day with the leave day intact.
- A half-day leave plus a full day of punches voids the leave day and refunds 0.50.
- A voided day writes a `reversal` ledger row, and the Story 5.1 reconciliation test still passes afterwards.
- A locked day never voids a leave, because it is never recalculated.
- Re-running `calculateDaily` on an already-voided day refunds nothing further.
- A day with an approved leave and **no** punches is still recorded as Leave, exactly as before this card.

### Definition of Done
Platform DoD plus:
- Real `LeaveDayResolver` registered in place of the null implementation from 3.2-BE.
- Both `calculateDaily` leave branches covered by tests, including the half-day carve-out in both directions.
- `voidLeaveDay()` idempotency test: calling it twice on the same day refunds once.
- A regression test asserting the no-punch leave day is unchanged.

---

## TASK 5.3-FE: Leave Approval — Frontend

**Task Type:** Frontend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.3a-BE Leave Approval
**Also needs (can be stubbed):** 5.3b-BE Punch Voids Leave · 4.1-FE Approval Settings
**Permission:** `attendance.leave-approve`
**Menu:** **Attendance › Leave › Approvals**
**Points:** 3

### Task Summary
*As HR, I want a leave queue where I can see the balance impact of a decision before I make it.*

### Related Tables
- `leave_requests` — see 5.2-BE.

### Frontend Routes
```
/attendance/leave/approvals
/attendance/leave/approvals/{id}
```

### Main Screen Sections
- **Leave queue** — pending requests with filters for employee, policy, status, and date range; row shows employee, policy, range, and day count.
- **Detail view** — request details, attachment preview, a balance before/after panel, the working-days breakdown showing which dates will be stamped, and the approval step history.
- **Approve / Reject actions** — reject opens a modal requiring a reason.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Open a request | `attendance.leave-approve` | `GET /leave-requests/{id}` | Detail with a **freshly fetched** balance panel — never carried from the list | — |
| 2 | **Approve** | balance panel finished loading | `POST /approval-requests/{id}/approve` | Balance deducted, days stamped, request `approved` | 422 balance became insufficient → **inline** with the current figure and a Refresh action |
| 3 | **Reject** | always | `POST /approval-requests/{id}/reject` | Request rejected; nothing else changes | 422 empty reason — the field has no default text |
| 4 | Review the day breakdown | detail open | — | The exact dates that will be stamped, with holidays visibly skipped | — |
| 5 | View an approved request | — | — | Per-day status: `active`, or **voided-by-punch with its void date** | — |

**Deliberately absent:** an Approve button while the balance panel is still loading. Approving against a stale figure is the failure this screen exists to prevent.

### UI Rules
- The balance panel is fetched fresh when the detail view opens, not carried from the list — it may have changed since submission.
- The working-days breakdown lists the exact dates that will be marked as leave, so an approver can see holidays being skipped.
- On an approved request, the day list shows each day's status — active or voided-by-punch, with the void date — so a request whose effective consumption differs from `total_days` explains itself.
- A 422 "balance became insufficient" renders inline on the detail view with the current figure and a refresh action, not as a toast.
- Approve is disabled while the balance panel is loading, so nobody approves against a stale figure.
- Reject's reason field has no default text and cannot be submitted empty.

### Acceptance Criteria
- An approver sees the balance impact and the exact dates affected before deciding.
- Rejecting without a reason is impossible.
- A stale-balance failure explains itself in place and offers a refresh.

### Definition of Done
Platform DoD plus:
- Reuses `<ApprovalStatusBadge />` from 4.1-FE.
- Nav entry under Attendance → Leave.

---

## TASK 5.4-BE: Attendance Correction Request — Backend

**Task Type:** Backend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 3.2-BE Daily Summary · 4.1-BE Approval Integration
**Permission:** `attendance.correction-create`
**Menu:** none — API only; surfaces in 5.4-FE
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `correction_requests` (new)

### Related DB Schema
**correction_requests**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `attendance_date` | date | Date being corrected |
| `request_type` | enum(`missing_in`,`missing_out`,`incorrect_time`,`wrong_status`,`other`) | Nature of the correction |
| `requested_check_in` | datetime nullable | Proposed check-in time |
| `requested_check_out` | datetime nullable | Proposed check-out time |
| `reason` | text | Mandatory justification |
| `attachment_path` | varchar(255) nullable | Supporting document |
| `status` | enum(`pending`,`approved`,`rejected`,`cancelled`) | Lifecycle |
| `decided_by` / `decided_at` | unsignedBigInteger nullable / timestamp nullable | Decision metadata |
| `decision_reason` | text nullable | Mandatory on reject |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, employee_id, attendance_date)` · `index(company_id, status)`

### API Endpoints
```
POST  /api/v1/attendance/correction-requests
GET   /api/v1/attendance/correction-requests?employee_id=me&status=
GET   /api/v1/attendance/correction-requests/{id}
PATCH /api/v1/attendance/correction-requests/{id}/cancel
```

### Business Rules
- An employee submits a correction for their **own** `attendance_date`, choosing a request type, giving a reason, and optionally attaching a document.
- The date must fall inside the correction window — `attendance.correction_window_days` from the module config, default 30 — counted back from today.
- A second `pending` request for the same `attendance_date` is blocked.
- A request against a date in a locked month is accepted but flagged; approving it later requires `attendance.correction-override-lock` (Story 5.5).
- On submit, routed through `ApprovalGateway::submit()` with `actionSlug: 'correction-approve'` and `correlationId: "correction_request:{id}"`.
- The employee may view status and cancel while `pending`.

### Validation Rules
- `attendance_date`, `request_type`, `reason` — required.
- `requested_check_in` required for `missing_in` and `incorrect_time`; `requested_check_out` required for `missing_out` and `incorrect_time`.
- Requested times must fall within the resolved shift's span for that date, allowing for an overnight shift.
- `attendance_date` not in the future.
- Attachment rules match the Employee module's document constraints.

### Error Handling
- Outside the correction window → **422** naming the window and the oldest permitted date.
- Second pending request for the same date → **409** referencing the existing request.
- Requested time outside the shift span → **422**.
- Submitting for another employee → **403**.

### Acceptance Criteria
- An employee can submit a correction with all mandatory fields validated per request type.
- A second pending request for the same date is blocked.
- A request older than the window is rejected at submission with the permitted range stated.
- A request whose proposed time falls outside the shift is rejected.
- The employee can track and cancel their own pending requests.

### Definition of Done
Platform DoD plus:
- Per-request-type conditional validation unit-tested.
- Correction window boundary tested at exactly the limit and one day past it.
- `api collection/Attendance/Correction Requests/*.yml`.

---

## TASK 5.4-FE: Attendance Correction Request — Frontend

**Task Type:** Frontend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.4-BE Correction Request
**Also needs (can be stubbed):** 3.2-FE Daily Summary
**Permission:** `attendance.correction-create`
**Menu:** **Attendance › Corrections › Requests** — also reachable pre-filled from the record detail (3.2-FE)
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `correction_requests` — see 5.4-BE.

### Frontend Routes
```
/attendance/corrections
/attendance/corrections/{id}
```

### Main Screen Sections
- **Add Correction form** — date picker limited to the correction window, request type dropdown, conditional time fields, reason, attachment upload; shows the day's current punches and status alongside the proposed values.
- **My Correction Requests list** — status badges, date, type.
- **Detail view** — original versus requested, side by side, with Cancel while pending.
- **Entry point from the record detail** — a "Request correction" action on the attendance record detail view (3.2-FE) that pre-fills the date.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Request correction** from a record | on the record detail (3.2-FE) | — | Opens this form with the date **pre-filled** | — |
| 2 | Pick a date | picker min derived from the API's correction window, never hardcoded | — | Dates with an existing pending request are disabled with a tooltip | — |
| 3 | Pick `request_type` | — | — | Only the time fields that type requires are rendered | — |
| 4 | **Submit** | `attendance.correction-create` | `POST /correction-requests` | Status `pending` | 422 outside window → permitted range stated; 409 second pending request for the date |
| 5 | **Cancel** | status = `pending` | `PATCH /{id}/cancel` | Cancelled | — |
| 6 | View detail | always | `GET /{id}` | Original values beside requested values | — |

### UI Rules
- The date picker's minimum is derived from the correction window returned by the API, not hardcoded in the client.
- Time fields appear and disappear with the request type, and only the ones the type requires are rendered.
- The current-values panel is always visible while composing, so the employee sees what they are changing.
- A date that already has a pending request is disabled in the picker with an explanatory tooltip.
- The form is reachable pre-filled from the attendance record detail, so nobody has to retype the date.

### Acceptance Criteria
- An employee raises a correction from the offending day in two clicks.
- Dates outside the window and dates with a pending request cannot be selected.
- The form shows current and proposed values together.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/correctionRequestApi.ts`.
- Action wired from the attendance record detail view; nav entry under Attendance.

---

## TASK 5.5-BE: Attendance Correction Approval — Backend

**Task Type:** Backend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.4-BE Correction Request · 3.2-BE Daily Summary · 4.1-BE Approval Integration
**Also needs (can be stubbed):** 3.1-BE Punches
**Permission:** `attendance.correction-approve` · `attendance.correction-override-lock`
**Menu:** none — API only; surfaces in 5.5-FE
**Points:** 5

### Task Summary
*As HR, I want to decide correction requests so attendance records stay accurate while the original punch history remains intact and auditable.*

### Related Tables
- `correction_requests`, `attendance_punches`, `attendance_records`, `approval_requests`

### API Endpoints
```
GET /api/v1/attendance/correction-requests?status=pending&employee_id=&from=&to=
GET /api/v1/attendance/correction-requests/{id}
```
Decisions use the platform's existing approve/reject endpoints, as in 5.3. **No approve or reject route is added here.**

### Business Rules
- HR can view, search, and filter correction requests by employee, org unit, status, and date range, within their visibility scope.
- `AttendanceCorrectionExecutor` (stub from 4.1) is implemented here and is the only place a correction is applied.
- **Punches are never mutated or deleted.** 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.
- After applying punches the executor recalculates the day via Story 3.2.
- Approving a correction whose month is locked requires `attendance.correction-override-lock`; the action is audit-logged prominently.
- On rejection a reason is mandatory and no punch or record changes.
- A finalised request cannot be decided again.
- Emits `CorrectionDecided`.

### Calculation Pseudocode
```
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)   # Story 3.2

            request.status = APPROVED
            request.save()

        emit CorrectionDecided(request)
```

| `request_type` | `buildPunchesFor` produces |
|---|---|
| `missing_in` | one new `in` punch at `requested_check_in`, replacing nothing |
| `missing_out` | one new `out` punch at `requested_check_out`, replacing nothing |
| `incorrect_time` | new `in` and/or `out` punches replacing the day's first `in` and last `out` |
| `wrong_status` | no punches; recalculation runs with an HR-set `attendance_type_id` override recorded in the activity log |
| `other` | no automatic punches; HR edits are captured in the request's decision reason |

### Validation Rules
- Rejection requires `decision_reason`, max 500.
- The resulting punch sequence must still satisfy the no-consecutive-same-type rule from 3.1; a correction that would break it is rejected.

### Error Handling
- Approving against a locked month without the override permission → **403**.
- Request already finalised → **409**.
- Resulting punch sequence invalid → **422** describing the conflict.
- Rejection without a reason → **422**.

### Acceptance Criteria
- Approving a pending request immediately updates the day's summary.
- The original punches remain in the table with `superseded_by_id` set — nothing is deleted or edited.
- The superseded punches are excluded from recalculation and from the consecutive-type check.
- HR cannot reject without a reason.
- A finalised request cannot be approved or rejected again.
- Approving a correction for a locked period requires the override permission and is audit-logged.
- The corrected day's `policy_snapshot` is preserved, not re-resolved (Story 2.2).

### Definition of Done
Platform DoD plus:
- `AttendanceCorrectionExecutor` implemented, registered, and tested for all five request types.
- A test asserts that no punch row is ever updated except its `superseded_by_id`, and none is deleted.
- Locked-month override path tested for both the permitted and forbidden caller.

---

## TASK 5.5-FE: Attendance Correction Approval — Frontend

**Task Type:** Frontend · **Part:** E — Business layer · **Module:** Attendance
**Start after:** 5.5-BE Correction Approval
**Also needs (can be stubbed):** 4.1-FE Approval Settings
**Permission:** `attendance.correction-approve` · `attendance.correction-override-lock`
**Menu:** **Attendance › Corrections › Approvals**
**Points:** 3

### Task Summary
*As HR, I want to compare the current day against what is being requested, and understand the consequence before approving.*

### Related Tables
- `correction_requests` — see 5.4-BE.

### Frontend Routes
```
/attendance/corrections/approvals
/attendance/corrections/approvals/{id}
```

### Main Screen Sections
- **Correction queue** — filters for employee, org unit, status, and date range.
- **Detail view** — a two-column comparison of the day's current punches and computed status against the requested values and the projected status; attachment preview; approval step history.
- **Approve / Reject actions** — reject opens a modal requiring a reason.
- **Locked-month banner** — shown when the target month is locked, naming the override permission required.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Open a request | `attendance.correction-approve` | `GET /correction-requests/{id}` | Two-column current vs requested, **plus the projected resulting status** | — |
| 2 | **Approve** | month not locked, **or** caller holds `correction-override-lock` | `POST /approval-requests/{id}/approve` | Superseding punches inserted, day recalculated | 422 resulting punch sequence invalid → error on the offending time field |
| 3 | **Approve** on a locked month | holds `correction-override-lock` | same | An **extra confirmation** naming the consequence fires first | — |
| 4 | **Approve** on a locked month | lacks the permission | — | Button **disabled** with a banner naming the permission needed — not hidden, so the user understands why | 403 never reached |
| 5 | **Reject** | always | `POST /approval-requests/{id}/reject` | Rejected; no punch or record changes | 422 empty reason |
| 6 | View superseded punches | detail open | — | Struck through in the current column, so repeat corrections are visible | — |

### UI Rules
- The comparison shows the projected status after correction, not just the raw times — the approver's real question is "what will this day become".
- Superseded punches from earlier corrections are shown struck through in the current column, so repeat corrections are visible.
- On a locked month, an approver without the override permission sees the banner and a disabled Approve button, not a hidden one.
- An approver with the override permission gets an extra confirmation step naming the consequence.
- A 422 about an invalid resulting punch sequence is rendered against the offending time field.

### Acceptance Criteria
- An approver sees current versus requested and the resulting status side by side.
- Locked-month approvals require a deliberate extra confirmation.
- An approver lacking the override permission understands why Approve is unavailable.

### Definition of Done
Platform DoD plus:
- Reuses `<ApprovalStatusBadge />` from 4.1-FE.
- Nav entry under Attendance → Corrections.

---

# Part F — Monthly Closing

## TASK 6.1-BE: Monthly Attendance Approval — Backend

**Task Type:** Backend · **Part:** F — Monthly closing · **Module:** Attendance
**Start after:** 3.2-BE Daily Summary · 4.1-BE Approval Integration
**Also needs (can be stubbed):** 5.3a-BE Leave Approval · 5.5-BE Correction Approval
**Permission:** `attendance.monthly-view` · `attendance.monthly-approve` · `attendance.monthly-unlock`
**Menu:** none — API only; surfaces in 6.1-FE
**Points:** 8

### Task Summary
*As HR, I want to review, approve, and lock a month's attendance so a payroll-ready summary exists for every employee.*

### Related Tables
- `monthly_attendance_approvals` (new)

### Related DB Schema
**monthly_attendance_approvals**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `month` / `year` | tinyint / smallint | Period |
| `total_present_days` | decimal(7,2) | Aggregated from `attendance_records` |
| `total_absent_days` | decimal(7,2) | Aggregated |
| `total_leave_days` | decimal(7,2) | Aggregated |
| `total_unpaid_leave_days` | decimal(7,2) | Leave against a policy where the days were unpaid; needed for payslip pro-rating |
| `total_half_days` | decimal(7,2) | Aggregated |
| `total_late_count` | int | Days with `late_minutes > 0` |
| `total_overtime_hours` | decimal(7,2) | Aggregated |
| `total_working_hours` | decimal(7,2) | Aggregated |
| `unresolved_flag` | boolean, default false | Pending correction/leave, missing check-out, or unassigned day exists |
| `status` | enum(`pending`,`approved`,`rejected`) | Review state |
| `is_locked` | boolean, default false | Blocks edits once true |
| `ready_for_payroll` | boolean, default false | Payroll reads only when true |
| `approved_by` / `approved_at` | unsignedBigInteger nullable / datetime nullable | Approver metadata |
| `frozen_at` / `frozen_by` | datetime nullable / unsignedBigInteger nullable | Set by Story 6.2 |
| `unfreeze_reason` | varchar(255) nullable | Mandatory when unfreezing |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, employee_id, month, year)` · `index(company_id, month, year, status)`

`total_unpaid_leave_days` is separate from `total_leave_days` because payslip pro-rating needs unpaid days only — paid leave must not reduce earnings.

### API Endpoints
```
POST /api/v1/attendance/monthly-attendance/build?month=&year=&employee_id=
GET  /api/v1/attendance/monthly-attendance?month=&year=&status=&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
```

`approve` and `bulk-approve` submit through `ApprovalGateway`; rejection is the platform's reject endpoint.

### Business Rules
- `build` aggregates each employee's month from `attendance_records` and approved leave, and is idempotent — re-running refreshes a `pending` row and refuses to touch an `approved` one.
- `unresolved_flag` is true when the month contains a pending correction, a pending leave request, a `missing_check_out` day, or an unassigned day.
- Approving a month with `unresolved_flag = true` requires an explicit `override: true` in the request body plus `attendance.monthly-approve`, and is audit-logged.
- Bulk approval operates across a filtered set and reports per-employee success or failure; one blocked employee does not fail the batch.
- `MonthlyAttendanceExecutor` (stub from 4.1) is implemented here: it sets `status = approved`, `is_locked = true` on every `attendance_records` row in the month, and `ready_for_payroll = true`.
- Rejection requires a reason and leaves the month editable.
- `unlock` requires `attendance.monthly-unlock` and a mandatory reason; it is **blocked once the month is frozen** (Story 6.2) — the freeze is the harder gate.
- Emits `MonthApproved`.

### Calculation Pseudocode
```
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 > 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 })
```

### Validation Rules
- `month` 1–12, `year` within ±5 of the current year.
- `override` — boolean, only honoured with `attendance.monthly-approve`.
- Unlock requires a reason, max 255.

### Error Handling
- Approving an unresolved month without `override` → **409**, response lists the unresolved reasons.
- Unlocking a frozen month → **409** stating that it must be unfrozen first.
- Rebuilding an approved month → **409**.

### Acceptance Criteria
- HR can approve a month for one employee or in bulk across a filtered group.
- A month with unresolved issues cannot be approved without an explicit override, and the response names each issue.
- Approved months are locked and flagged `ready_for_payroll`; the locked records reject automatic recalculation.
- An authorised user can unlock an approved month before it is frozen, with the reason recorded.
- Unlock is refused once the month is frozen.
- Re-running `build` on a pending month refreshes it; on an approved month it is refused.
- `total_unpaid_leave_days` counts only leave taken against an unpaid policy.

### Definition of Done
Platform DoD plus:
- `MonthlyAttendanceExecutor` implemented, registered, and tested.
- Aggregation unit-tested against a month containing every status, including a paid and an unpaid leave.
- Bulk approval partial-failure path tested.
- `api collection/Attendance/Monthly Approval/*.yml`.

---

## TASK 6.1-FE: Monthly Attendance Approval — Frontend

**Task Type:** Frontend · **Part:** F — Monthly closing · **Module:** Attendance
**Start after:** 6.1-BE Monthly Approval
**Also needs (can be stubbed):** 4.1-FE Approval Settings
**Permission:** `attendance.monthly-view` · `attendance.monthly-approve` · `attendance.monthly-unlock`
**Menu:** **Attendance › Monthly Approval**
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `monthly_attendance_approvals` — see 6.1-BE.

### Frontend Routes
```
/attendance/monthly-approval
/attendance/monthly-approval/{id}
```

### Main Screen Sections
- **Monthly grid** — employees as rows, summary columns, month/year and department filters, and an unresolved-issue indicator per row.
- **Unresolved detail popover** — lists the specific blockers for a row with links to each pending correction, pending leave, or missing-check-out day.
- **Bulk Approve action** — across the filtered or selected set, followed by a per-employee result table.
- **Reject action** — modal requiring a reason.
- **Unlock action** — modal requiring a reason; hidden once the month is frozen.
- **Employee monthly detail** — day-by-day breakdown behind the summary numbers.

### API Integration
```
POST /api/v1/attendance/monthly-attendance/build?month=&year=
GET  /api/v1/attendance/monthly-attendance?month=&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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Build / rebuild month** | `attendance.monthly-view` | `POST /monthly-attendance/build` | Grid populates or refreshes | 409 on an already-approved month |
| 2 | Click the **unresolved indicator** | row has `unresolved_flag` | — | Popover lists the blockers, each deep-linked to the pending correction, pending leave, or missing-check-out day | — |
| 3 | **Approve** one | `monthly-approve`, row resolved | `POST /{id}/approve` | Month approved, records locked, `ready_for_payroll` set | 409 unresolved without override → response lists each reason |
| 4 | **Approve with override** | `monthly-approve` | same, `override: true` | Approved as-is | Checkbox is **off by default** and states that unresolved issues will be approved as-is |
| 5 | **Bulk approve** | `monthly-approve` | `POST /bulk-approve` | Per-employee result table | Pre-flight count of unresolved rows shown **before** it runs |
| 6 | **Reject** | `monthly-approve` | `POST /approval-requests/{id}/reject` | Month stays editable | 422 empty reason |
| 7 | **Unlock** | `monthly-unlock` **and** month not frozen | `POST /{id}/unlock` | Month editable again, reason recorded | Once frozen the action is **hidden entirely**, not disabled |
| 8 | Click a summary figure | always | `GET /{id}/breakdown` | Day-level detail — no number is a dead end | — |

### UI Rules
- 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.
- The override checkbox on approval is off by default and states plainly that unresolved issues will be approved as-is.
- Bulk approve shows a pre-flight count of how many rows are unresolved before it runs.
- The result table after a bulk run separates succeeded from failed rows with per-row reasons.
- Frozen months show a distinct badge and hide Unlock entirely rather than disabling it.
- Summary columns link into the day-level breakdown, so no number is a dead end.

### Acceptance Criteria
- HR can see, in one screen, which employees are blocked and open each blocker directly.
- Bulk approving a filtered department reports per-employee outcomes.
- Overriding unresolved issues requires a deliberate action.
- Unlock disappears once a month is frozen.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/monthlyAttendanceApi.ts`.
- Nav entry under Attendance → Monthly Approval.

---

## TASK 6.2-BE: Explicit Monthly Freeze State — Backend

**Task Type:** Backend · **Part:** F — Monthly closing · **Module:** Payroll
**Start after:** 6.1-BE Monthly Approval
**Permission:** `payroll.month-freeze` · `payroll.month-unfreeze-paid`
**Menu:** none — API only; surfaces in 6.2-FE
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `monthly_attendance_approvals` (columns already added in 6.1: `frozen_at`, `frozen_by`, `unfreeze_reason`)

### API Endpoints
```
POST /api/v1/payroll/monthly-attendance/{id}/freeze
POST /api/v1/payroll/monthly-attendance/{id}/unfreeze
GET  /api/v1/payroll/monthly-attendance?frozen=true&month=&year=
```

These live in the **Payroll** module because freezing is a Finance responsibility, while approving is HR's.

### Business Rules
- Freeze requires `status = approved`. A month cannot be frozen before HR has approved it.
- `payroll.month-freeze` is a separate permission from `attendance.monthly-approve`, and Story 0.3 guarantees no seeded role holds both.
- Freezing publishes `MonthFrozen`, which is what makes the month eligible for snapshot building (Story 8.0).
- Unfreezing requires a mandatory reason recorded in `unfreeze_reason`.
- Unfreezing a month whose payroll run is already `paid` requires `payroll.month-unfreeze-paid` and is prominently audit-logged.
- Approve and Freeze are two separately audit-logged actions, potentially by two different users.

### Validation Rules
- Freeze — target month `status` must be `approved`.
- Unfreeze — `unfreeze_reason` required, max 255.

### Error Handling
- Freeze on a non-approved month → **409**.
- Unfreeze without a reason → **422**.
- Unfreeze on a month with a paid run, without the elevated permission → **403**.

### Acceptance Criteria
- A month must be approved before it can be frozen.
- A user holding `attendance.monthly-approve` but not `payroll.month-freeze` receives 403 from the freeze endpoint.
- Approve and freeze produce two distinct audit entries with their own actors and timestamps.
- Unfreezing a paid month requires the elevated permission and is logged.
- `MonthFrozen` is dispatched on freeze and consumed by the snapshot builder.

### Definition of Done
Platform DoD plus:
- Permission separation verified by a test asserting that the approve permission alone cannot freeze.
- `MonthFrozen` wired to the snapshot-eligibility check.
- `api collection/Payroll/Monthly Freeze/*.yml`.

---

## TASK 6.2-FE: Explicit Monthly Freeze State — Frontend

**Task Type:** Frontend · **Part:** F — Monthly closing · **Module:** Payroll
**Start after:** 6.2-BE Monthly Freeze
**Also needs (can be stubbed):** 6.1-FE Monthly Approval
**Permission:** `payroll.month-freeze` · `payroll.month-unfreeze-paid`
**Menu:** **Payroll › Monthly Freeze** — deliberately in the Payroll menu, not beside Monthly Approval, because freezing is a Finance responsibility (segregation of duties)
**Points:** 2

### Task Summary
*As Finance, I want Approved, Frozen, and Paid to be visibly different states, so the payroll lock is never confused with HR sign-off.*

### Related Tables
- `monthly_attendance_approvals` — see 6.1-BE.

### Frontend Routes
```
/payroll/monthly-freeze
/payroll/monthly-freeze/{id}
```

### Main Screen Sections
- **Four-state status badge** — Pending / Approved / Frozen / Paid, each visually distinct, used on both this screen and the monthly approval grid.
- **Freeze action** — a separate button from Approve, rendered only for holders of `payroll.month-freeze`.
- **Unfreeze action** — reason modal; an additional confirmation step and a strong warning when the run is already paid.

### API Integration
```
POST /api/v1/payroll/monthly-attendance/{id}/freeze
POST /api/v1/payroll/monthly-attendance/{id}/unfreeze
GET  /api/v1/payroll/monthly-attendance?frozen=true
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Freeze** | `payroll.month-freeze` **and** status = `approved` | `POST /monthly-attendance/{id}/freeze` | State badge → Frozen; month becomes snapshot-eligible | Disabled with a stated reason until approved; 409 otherwise |
| 2 | **Freeze** without the permission | — | — | Control is **not rendered at all** — an HR approver never sees a button they cannot use | 403 never reached |
| 3 | **Unfreeze** | `payroll.month-freeze` | `POST /{id}/unfreeze` | Back to Approved, reason recorded | 422 empty reason |
| 4 | **Unfreeze a paid month** | `payroll.month-unfreeze-paid` | same | Requires typing the month name to confirm | 403 without the elevated permission |

### UI Rules
- Freeze is disabled with an explanatory tooltip until the month is approved — the reason is stated, not implied.
- The Freeze control is not rendered at all for users without the permission, so an HR approver never sees a button they cannot use.
- The four states use distinct colour and iconography; Approved and Frozen must not look alike at a glance.
- Unfreezing a paid month requires typing the month name to confirm, matching the weight of the action.

### Acceptance Criteria
- A user can distinguish approved-but-not-frozen from frozen at a glance.
- Freeze is invisible to users lacking `payroll.month-freeze`.
- Unfreezing a paid month cannot happen without a deliberate confirmation.

### Definition of Done
Platform DoD plus:
- Shared `<MonthStateBadge />` component reused by 6.1-FE and 8.x screens.
- Nav entry under Payroll.

---

# Part G — Payroll Configuration

Part G depends only on Part 0 and can be built in parallel with Parts A–F.

## TASK 7.0-BE: Payroll Settings — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `payroll.settings-manage`
**Menu:** none — API only; surfaces in 7.0-FE
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `payroll_settings` (new)

### Related DB Schema
**payroll_settings**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Unique — one settings row per company |
| `overtime_multiplier` | decimal(4,2), default 1.50 | Multiplier applied to the hourly rate |
| `overtime_rate_base` | enum(`gross`,`basic`), default `basic` | Which figure the hourly rate derives from |
| `standard_monthly_hours` | decimal(6,2), default 208 | Divisor for the fixed-hours method |
| `hourly_rate_method` | enum(`fixed_monthly_hours`,`working_days_x_shift_hours`), default `fixed_monthly_hours` | How the hourly rate is derived |
| `prorate_method` | enum(`working_days`,`calendar_days`), default `working_days` | Basis for unpaid-day pro-rating |
| `round_net_pay_to` | decimal(4,2), default 1.00 | Rounding step for net pay |
| `advance_enabled` | boolean, default false | **Company-wise switch** — off hides the advance menu and 409s its endpoints |
| `advance_default_method` | enum(`fixed`,`percentage`), default `percentage` | Prefills the request form |
| `advance_default_value` | decimal(12,2), default 50.00 | Amount, or percent (50.00 = 50%) |
| `advance_max_percentage` | decimal(5,2), default 50.00 | Ceiling on the month's total as a share of gross |
| `advance_max_amount` | decimal(12,2) nullable | Absolute ceiling; null means no absolute cap |
| `advance_requires_approval` | boolean, default true | Off lets `payroll.advance-manage` holders approve directly |
| `updated_by` | unsignedBigInteger nullable | Actor |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id)`

### API Endpoints
```
GET /api/v1/payroll/settings
PUT /api/v1/payroll/settings
```

### Business Rules
- The row is created with defaults on first read; there is no create endpoint.
- Hourly rate, used by payslip generation (Story 8.2):

```
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)
```

- Changing settings affects **future** 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.
- The source task-card document referenced `OT_MULTIPLIER` and `hourlyRate()` without defining either. This story is where they are defined.
- **Salary advance policy (spec R3).** `advance_enabled` is the company-wise switch: some companies pay part of a month's salary early when payroll runs late, most do not. When it is false, every Story 7.5 endpoint returns 409 and the nav item is hidden.
- The two ceilings are checked against the **month's running total** of an employee's advances, not against a single request — see Story 7.5. This story only stores them.
- Turning `advance_enabled` off does not delete or invalidate advances already recorded; they still settle on their payslips. It only blocks new ones.
- `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.

### Validation Rules
- `overtime_multiplier` — between 0 and 5.
- `standard_monthly_hours` — greater than 0, at most 744.
- `round_net_pay_to` — one of 0.01, 0.10, 1.00, 10.00.
- `overtime_rate_base = basic` requires that structures in use define a basic component; validated as a warning, not a block.
- `advance_max_percentage` — greater than 0 and at most 100.
- `advance_max_amount` — greater than 0 when present.
- `advance_default_value` — greater than 0; at most 100 when `advance_default_method` is `percentage`.
- `advance_default_value` must not itself exceed `advance_max_percentage` / `advance_max_amount`, otherwise the prefilled form would always fail the ceiling.

### Error Handling
- Update by a caller without `payroll.settings-manage` → **403**.

### Acceptance Criteria
- A first `GET` returns a settings row populated with the documented defaults.
- Updating the multiplier changes overtime on a newly generated payslip and leaves existing finalized payslips untouched.
- Both hourly-rate methods are unit-tested against the same salary and produce the expected different figures.
- A company with `advance_enabled = false` receives 409 from every Story 7.5 endpoint.
- Setting `advance_default_value = 60` with `advance_max_percentage = 50` returns 422.

### Definition of Done
Platform DoD plus:
- `hourlyRate()` implemented as a single shared service method consumed by Story 8.2 — not duplicated in the generator.
- `advance_enabled` exposed on the settings resource so the frontend can gate its nav item from one read.
- `api collection/Payroll/Settings/*.yml`.

---

## TASK 7.0-FE: Payroll Settings — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.0-BE Payroll Settings
**Permission:** `payroll.settings-manage`
**Menu:** **Payroll › Configuration › Payroll Settings**
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `payroll_settings` — see 7.0-BE.

### Frontend Routes
```
/payroll/settings
```

### Main Screen Sections
- **Settings form** — overtime group, hourly-rate group, pro-rating group, rounding.
- **Salary advance group** — the `advance_enabled` master switch, and beneath it the default method, default value, the two ceilings, and the approval-required toggle.
- **Worked example panel** — 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.

### API Integration
```
GET /api/v1/payroll/settings
PUT /api/v1/payroll/settings
```

### UI Rules
- The worked example recomputes live as fields change, before saving — this is the screen's main value.
- `standard_monthly_hours` is hidden when the method is `working_days_x_shift_hours`, since it is unused there.
- 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.
- 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.
- A save banner states plainly that changes apply to future generation only and lists how many draft payroll runs would be affected on regeneration.

### Acceptance Criteria
- Finance can see the numeric effect of each setting before saving.
- Irrelevant fields are hidden rather than disabled.
- The forward-only impact of a change is stated on save.
- Toggling the advance switch shows or hides the entire Salary Advances nav item without a page reload.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/payrollSettingsApi.ts`.
- Nav entry under Payroll.

---

## TASK 7.1-BE: Salary Structure Management — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `payroll.structure-manage`
**Menu:** none — API only; surfaces in 7.1-FE
**Points:** 3

### Task Summary
*As HR, I want to manage named salary structures as templates that group employees for component assignment.*

### Related Tables
- `salary_structures` — **existing table, no migration required**

### Related DB Schema
**salary_structures (existing — aligned to the shipped migration)**

| Field | Type | Description |
|---|---|---|
| `id` | bigint unsigned PK | Primary key |
| `company_id` | bigint unsigned nullable | Nullable allows a system-wide default structure |
| `name` | varchar(150) | Structure name |
| `code` | varchar(50) | Unique per `company_id` |
| `requires_basic` | boolean, default true | Whether a Basic component is mandatory |
| `status` | varchar(20), default `Active` | Active / Inactive |
| `created_by` / `updated_by` | bigint unsigned nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Existing keys: `unique(company_id, code)` · `index(company_id, status)`

### API Endpoints
```
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
```

### Business Rules
- **Ownership decision:** 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.
- `salary_structures` is a named container only. Its earning and deduction components live in `salary_structure_components` (Story 7.2).
- `code` is unique per company.
- A structure with `requires_basic = true` cannot be activated until exactly one component with `is_basic = true` exists.
- Deactivating a structure blocks new `employee_salaries` assignments against it but leaves existing assignments untouched.
- The list endpoint returns a component count and a `ready_to_activate` boolean so the UI can explain blocked activation without a second call.

### Validation Rules
- `name`, `code` — required; `code` unique per `company_id`.
- `requires_basic` — boolean.

### Error Handling
- Duplicate code within company → **409**.
- Activate without a basic component while `requires_basic = true` → **422** naming the missing component.

### Acceptance Criteria
- HR can create a structure with a code unique to their company.
- A `requires_basic` structure cannot be activated until a basic component exists, and the error says so.
- Deactivated structures are absent from new salary-assignment pickers but remain on existing `employee_salaries` rows.
- No migration was added for this table.

### Definition of Done
Platform DoD plus:
- Implemented against the existing schema with **no migration**.
- `ready_to_activate` derivation unit-tested.
- `api collection/Payroll/Salary Structures/*.yml`.

---

## TASK 7.1-FE: Salary Structure Management — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.1-BE Salary Structures
**Permission:** `payroll.structure-manage`
**Menu:** **Payroll › Configuration › Salary Structures**
**Points:** 2

### Task Summary
*As HR, I want to manage salary structure templates and see at a glance whether each one is complete enough to activate.*

### Related Tables
- `salary_structures` — see 7.1-BE.

### Frontend Routes
```
/payroll/salary-structures
/payroll/salary-structures/{id}
```

### Main Screen Sections
- **Structure list** — name, code, component count, readiness indicator, status.
- **Add / Edit form** — name, code, `requires_basic` toggle.
- **Structure detail** — summary plus a link into its components (Story 7.2).

### API Integration
```
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
```

### UI Rules
- Activate is disabled with a tooltip naming the missing basic component when `ready_to_activate` is false — the blocker is stated, never implied.
- The readiness indicator appears in the list, so HR does not have to open each structure to find the incomplete one.
- The `requires_basic` toggle warns when switched on for a structure that has no basic component yet.

### Acceptance Criteria
- HR can see from the list which structures are ready to activate.
- A blocked activation explains exactly what is missing.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/salaryStructureApi.ts`.
- Nav entry under Payroll.

---

## TASK 7.2-BE: Salary Structure Components — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.1-BE Salary Structures
**Permission:** `payroll.structure-manage`
**Menu:** none — API only; surfaces in 7.2-FE
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `salary_structure_components` (new)
- `salary_structures` (existing)

### Related DB Schema
**salary_structure_components** — new; this closes the gap found when the shipped schema was reviewed.

| Field | Type | Description |
|---|---|---|
| `id` | bigint unsigned PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `salary_structure_id` | unsignedBigInteger | `salary_structures.id` |
| `component_name` | varchar(100) | e.g. Basic, House Rent, Conveyance, Provident Fund |
| `component_code` | varchar(50) | Unique per structure; the key used in payslip breakdown JSON |
| `component_type` | enum(`earning`,`deduction`) | Adds to or subtracts from gross |
| `is_basic` | boolean, default false | At most one per structure |
| `calculation_type` | enum(`fixed`,`percentage`) | Fixed amount or percentage of a base |
| `value` | decimal(12,2) | Fixed amount, or percentage value (25.00 = 25%) |
| `percentage_base` | enum(`gross`,`basic`) nullable | Required when `calculation_type = percentage` |
| `is_taxable` | boolean, default true | Included in the taxable base (Story 7.3) |
| `prorated` | boolean, default true | Whether unpaid days reduce this component |
| `display_order` | int, default 0 | Order on the payslip |
| `status` | varchar(20), default `Active` | Active / Inactive |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(salary_structure_id, component_code)` · `index(company_id, salary_structure_id)`

`prorated` exists because a fixed reimbursement should not shrink with absence, while a salary component should. Pro-rating every earning unconditionally, as the source document did, is wrong for allowances.

### API Endpoints
```
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
```

`preview` returns a sample payslip breakdown for a supplied gross salary, without persisting anything.

### Business Rules
- A structure with `requires_basic = true` must contain exactly one component with `is_basic = true`.
- `percentage_base` is required when `calculation_type = percentage` and ignored when `fixed`.
- The sum of percentage earnings against the same base exceeding 100% is a **soft warning** in the response, not a block — fixed top-ups can legitimately coexist.
- Payslip generation (Story 8.2) reads components from this table. There is no JSON component field anywhere.
- `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.
- Deleting the sole basic component of a `requires_basic` structure is blocked.

### Validation Rules
- `component_name`, `component_code`, `component_type`, `calculation_type`, `value` — required.
- `component_code` — uppercase alphanumeric plus underscore, unique per structure.
- `value` — greater than 0.
- `percentage_base` — required when `calculation_type = percentage`.
- `is_basic = true` — allowed only when `component_type = earning`, and only once per structure.

### Error Handling
- Deleting the sole basic component while `requires_basic = true` → **409**.
- Second `is_basic` component → **422**.
- Missing `percentage_base` on a percentage component → **422**.
- Changing `component_code` on a structure referenced by a payslip → **409**.

### Acceptance Criteria
- HR can add, edit, reorder, and remove components under a structure.
- Removing the last basic component from a `requires_basic` structure is blocked with a clear message.
- A percentage component without a base is rejected.
- Percentage earnings over 100% of the same base return a warning and still save.
- Payslip generation reads from this table, verified by an integration test in Story 8.2.
- A non-prorated component keeps its full value on a payslip with unpaid days.

### Definition of Done
Platform DoD plus:
- Migration for `salary_structure_components`.
- `preview` and the real generator share one calculation path for the earnings section.
- `api collection/Payroll/Salary Structure Components/*.yml`.

---

## TASK 7.2-FE: Salary Structure Components — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.2-BE Structure Components
**Permission:** `payroll.structure-manage`
**Menu:** **Payroll › Configuration › Salary Structures** — embedded builder on the structure detail (7.1-FE), no own nav item
**Points:** 5

### Task Summary
*As HR, I want to build a structure's components on one screen with a live payslip preview, so the result is visible before anyone is paid by it.*

### Related Tables
- `salary_structure_components` — see 7.2-BE.

### Frontend Routes
```
/payroll/salary-structures/{id}/components
```

### Main Screen Sections
- **Component builder** — repeatable rows with name, code, type, calculation type, value, percentage base, taxable flag, prorated flag, and drag-to-reorder.
- **Live preview panel** — a sample payslip breakdown for an editable sample gross, updating as components change.
- **Warning banner** — appears when percentage components exceed 100% of their base.

### API Integration
```
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
```

### UI Rules
- The percentage-base field is rendered only for percentage components.
- The preview comes from the `preview` endpoint, not client-side maths, so it cannot drift from the real generator.
- Earnings and deductions are visually grouped and separately subtotalled, matching the payslip layout.
- The `prorated` flag carries a one-line explanation of its effect, because its meaning is not obvious from the label.
- `component_code` becomes read-only once the API reports the structure is referenced by a payslip, with a tooltip explaining why.
- The over-100% warning is non-blocking and dismissible, since it is legitimate in some structures.

### Acceptance Criteria
- HR can build a full component set and see the resulting payslip before saving.
- The preview matches what generation later produces for the same gross.
- Reordering changes the preview and the persisted display order.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/salaryStructureComponentApi.ts`.
- Drag-to-reorder with optimistic update and rollback.

---

## TASK 7.3-BE: Tax Slab Configuration — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `payroll.settings-manage`
**Menu:** none — API only; surfaces in 7.3-FE
**Points:** 3

### Task Summary
*As Finance, I want to configure income tax slabs so payroll calculates statutory deductions correctly.*

### Related Tables
- `tax_slabs` (new)

### Related DB Schema
**tax_slabs**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `name` | varchar(150) | Slab set name (e.g. FY2026 Individual) |
| `effective_year` | smallint | Tax year this applies to |
| `slabs` | json | Ordered array of `{min_income, max_income, rate}`; `max_income: null` on the top bracket |
| `status` | varchar(20), default `Inactive` | Active / Inactive |
| `created_by` / `updated_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, effective_year, status)`

"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.

### API Endpoints
```
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
```

`calculate` returns the tax for a supplied annual income against a given slab set — used by the UI preview and by tests.

### Business Rules
- A slab set defines income brackets and rates for one effective year.
- **Brackets must be contiguous, non-overlapping, and ascending, with exactly one open-ended top bracket.** A gap between brackets silently under-taxes, which is why this is validated rather than assumed.
- Only one Active set per company per effective year. Activating a second one deactivates the first inside the same transaction.
- The Active set for the run's year is used during payslip generation (Story 8.2).
- A set referenced by any generated payslip cannot be deleted or have its `slabs` edited — create a new set instead. Renaming remains allowed.

### Calculation Pseudocode
```
function calculateSlabTax(annual_income, slabs):
    tax = 0
    for slab in slabs order by min_income asc:
        if annual_income <= 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 @ 0%, 350000–700000 @ 10%, 700000+ @ 15%], income 720000
#   band 1: (350000 - 0)      * 0%  = 0
#   band 2: (700000 - 350000) * 10% = 35000
#   band 3: (720000 - 700000) * 15% = 3000
#   total                           = 38000
```

### Validation Rules
- `name`, `effective_year`, `slabs` — required.
- `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.
- `effective_year` — within ±5 years of the current year.

### Error Handling
- Non-contiguous or overlapping brackets → **422** naming the offending pair.
- More than one open-ended bracket, or an open-ended bracket that is not last → **422**.
- Activating a second set for the same year → succeeds, deactivating the first; the response says so.
- Editing `slabs` on a referenced set → **409**.

### Acceptance Criteria
- Finance can define a set with multiple brackets and rates.
- A set with a gap between brackets is rejected with the offending pair named.
- Activating a new set for a year deactivates the previous one automatically.
- `calculate` returns 38 000 for the worked example above.
- A set used by a generated payslip cannot have its brackets edited.

### Definition of Done
Platform DoD plus:
- `calculateSlabTax` implemented once as a shared service method, consumed by Story 8.2.
- Bracket-continuity validation unit-tested against gaps, overlaps, and a misplaced open-ended band.
- `api collection/Payroll/Tax Slabs/*.yml`.

---

## TASK 7.3-FE: Tax Slab Configuration — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.3-BE Tax Slabs
**Permission:** `payroll.settings-manage`
**Menu:** **Payroll › Configuration › Tax Slabs**
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `tax_slabs` — see 7.3-BE.

### Frontend Routes
```
/payroll/tax-slabs
/payroll/tax-slabs/{id}
```

### Main Screen Sections
- **Slab list** — grouped by effective year, with the Active set marked.
- **Add / Edit form** — repeatable bracket rows (min, max, rate) with add, remove, and automatic ordering.
- **Continuity indicator** — a visual band chart showing coverage and highlighting any gap or overlap.
- **Tax preview** — an income input showing the computed tax and the per-band contribution.
- **Activate toggle** — with a confirmation naming the set it will replace.

### API Integration
```
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
```

### UI Rules
- Each bracket's `min_income` auto-fills from the previous row's `max_income` and is read-only, which makes gaps structurally impossible in the common path.
- The last row's `max_income` is fixed as "and above" and cannot be given a value.
- The band chart marks any gap in red before submission — the failure mode this guards against is silent under-taxation.
- The tax preview shows the per-band breakdown, not just a total, so the figure is checkable by hand.
- Activating a set states which set will be deactivated, by name.
- Bracket fields are read-only on a referenced set, with an explanation and a "duplicate as new set" action.

### Acceptance Criteria
- Finance can build a complete bracket set without being able to leave a gap.
- The preview's per-band figures match the backend `calculate` response.
- Activating a set clearly states what it replaces.
- A referenced set offers duplication instead of editing.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/taxSlabApi.ts`.
- Nav entry under Payroll.

---

## TASK 7.4-BE: Employee Deductions & Loans — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 0.3-BE Permission Registry
**Permission:** `payroll.deduction-manage`
**Menu:** none — API only; surfaces in 7.4-FE
**Points:** 5

### Task Summary
*As HR, I want to track recurring deductions and loans per employee so they are applied automatically across payroll runs, exactly once each.*

### Related Tables
- `employee_deductions` (new)
- `employee_deduction_entries` (new)

### Related DB Schema
**employee_deductions**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `type` | enum(`loan`,`advance`,`fine`,`other`) | Kind of deduction |
| `total_amount` | decimal(12,2) | Original amount |
| `remaining_balance` | decimal(12,2) | Amount still outstanding |
| `installment_amount` | decimal(12,2) | Per-run deduction |
| `start_month` / `start_year` | tinyint / smallint | When deduction begins |
| `status` | enum(`active`,`completed`,`cancelled`) | Lifecycle |
| `remarks` | text nullable | Free-text note |
| `created_by` / `updated_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, employee_id, status)`

**employee_deduction_entries**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `employee_deduction_id` | unsignedBigInteger | Parent deduction |
| `payroll_run_id` | unsignedBigInteger | Which run applied it |
| `payslip_id` | unsignedBigInteger nullable | Which payslip carried it |
| `amount` | decimal(12,2) | Applied amount |
| `created_at` | timestamp | When |

Keys: `unique(employee_deduction_id, payroll_run_id)`

Without the entries table, regenerating a draft payslip decrements `remaining_balance` a second time. The unique key makes application idempotent per run.

### API Endpoints
```
GET   /api/v1/payroll/employee-deductions?employee_id=&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
```

### Business Rules
- HR records a deduction or loan with a type, total, installment, and start period.
- `installment_amount` is applied to each eligible payroll run from the start period until `remaining_balance` reaches zero, at which point `status` becomes `completed`.
- **Application is idempotent per run.** Regenerating a payslip reverses that run's existing entry before re-applying, so the balance never double-decrements.
- If applying an installment would drive net pay below zero, it is skipped, the payslip is flagged `needs_review`, and the balance is unchanged.
- Cancelling stops future installments and does not alter already-generated payslips.
- `PATCH` may change `installment_amount` and `remarks` only; changes take effect from the next run.

### Calculation Pseudocode
```
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 <= 0:                                return 0

    if projected_net - amount < 0:
        flagForHrReview(deduction, run)
        return 0

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

    return amount
```

### Validation Rules
- `type`, `total_amount`, `installment_amount`, `start_month`, `start_year` — required.
- `total_amount` and `installment_amount` — greater than 0; installment not greater than total.
- `start_month` 1–12.
- `remaining_balance` is set to `total_amount` on creation and is not directly writable.

### Error Handling
- `installment_amount > total_amount` → **422**.
- Editing a `completed` or `cancelled` deduction → **409**.
- Attempting to write `remaining_balance` directly → **422**.

### Acceptance Criteria
- A loan created with a total and a monthly installment appears in subsequent payslips automatically.
- The remaining balance decreases by exactly one installment per run, even when the payslip is regenerated three times.
- A deduction reaching zero is marked completed and stops appearing.
- Cancelling stops future installments and leaves generated payslips untouched.
- An installment that would push net pay negative is skipped and the payslip is flagged.
- The entries endpoint shows one row per run, matching the balance history.

### Definition of Done
Platform DoD plus:
- Migration for both tables.
- Idempotency test: generate, regenerate twice, assert a single balance decrement.
- Negative-net-pay skip path tested.
- `api collection/Payroll/Employee Deductions/*.yml`.

---

## TASK 7.4-FE: Employee Deductions & Loans — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.4-BE Deductions & Loans
**Permission:** `payroll.deduction-manage`
**Menu:** **Payroll › Deductions & Loans**
**Points:** 3

### Task Summary
*As HR, I want to see each loan's repayment progress and the runs it was applied in, so a disputed deduction can be answered from one screen.*

### Related Tables
- `employee_deductions`, `employee_deduction_entries` — see 7.4-BE.

### Frontend Routes
```
/payroll/employee-deductions
/payroll/employee-deductions/{id}
```

### Main Screen Sections
- **Deduction list** — employee, type, total, remaining, a paid-versus-remaining progress bar, status.
- **Add form** — type, total amount, installment amount, start period, remarks, with a derived "N installments, completing in Month Year" line.
- **Detail view** — the deduction plus its per-run entry history with links to each payslip.
- **Cancel action** — confirmation stating that generated payslips are unaffected.

### API Integration
```
GET   /api/v1/payroll/employee-deductions?employee_id=&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
```

### UI Rules
- The projected completion month is computed and shown while composing, so an unrealistic installment is obvious before saving.
- The entry history links each applied amount to its payslip — this is the screen HR opens when an employee queries a deduction.
- Edit exposes only the installment amount and remarks; the total and start period are read-only after creation, with an explanation.
- Cancel states explicitly that past payslips are not altered, so nobody expects a refund from it.
- A deduction skipped in a run because of negative net pay is flagged in the entry history with the reason.

### Acceptance Criteria
- HR can see repayment progress at a glance across all employees.
- Every applied installment links to the payslip that carried it.
- A skipped installment is visible with its reason.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/employeeDeductionApi.ts`.
- Nav entry under Payroll.

---

## TASK 7.5-BE: Salary Advances — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.0-BE Payroll Settings · 4.1-BE Approval Integration
**Permission:** `payroll.advance-manage` · approval on `payroll.advance-approve`
**Menu:** none — API only; surfaces in 7.5-FE
**Points:** 8

### Task Summary
*As HR, I want to record a part-payment of an employee's own salary when payroll runs late, so the employee gets money now and that month's payslip pays only the balance.*

### Related Tables
- `salary_advances` (new)
- `payroll_settings` — the company policy, see 7.0-BE
- `employee_salaries` — the priced-from salary row
- `payslips` — settlement target, see 8.2b-BE

### Related DB Schema
**salary_advances**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `month` / `year` | tinyint / smallint | The payroll period this is advanced against |
| `employee_salary_id` | unsignedBigInteger | The salary row it was priced from |
| `request_method` | enum(`fixed`,`percentage`) | How the amount was expressed |
| `requested_value` | decimal(12,2) | Amount, or percent (50.00 = 50%) |
| `basis_gross` | decimal(12,2) | `employee_salaries.gross_salary` **frozen at request time** |
| `amount` | decimal(12,2) | Resolved payable amount |
| `status` | enum(`draft`,`pending_approval`,`approved`,`rejected`,`paid`,`settled`,`cancelled`) | Lifecycle |
| `reason` | text nullable | Why the advance was requested |
| `payment_channel` | enum(`cash`,`cheque`,`bank`,`mobile_banking`) nullable | How HR handed the money over |
| `payment_reference` | varchar(100) nullable | Cheque no. / txn id / voucher no. |
| `paid_at` / `paid_by` | datetime / unsignedBigInteger nullable | Handover record |
| `settled_payslip_id` | unsignedBigInteger nullable | The payslip that netted it off |
| `settled_at` | datetime nullable | When settlement happened |
| `created_by` / `approved_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, employee_id, year, month)`, `index(company_id, status)`

**This is not `employee_deductions.type = 'advance'`.** That row is a *loan*: money lent, recovered as installments over several months. A `salary_advances` row is a *part-payment of the employee's own salary for that same month* — no installments, no interest, settled inside that month's payslip. Merging them makes both the loan report and the advance report wrong, which is why the tables are separate.

`basis_gross` is frozen because a mid-month salary revision must not retroactively change what a percentage-based advance was worth.

### API Endpoints
```
GET   /api/v1/payroll/salary-advances?employee_id=&month=&year=&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=&month=&year=
```

### Business Rules
- Every endpoint returns **409** when `payroll_settings.advance_enabled = false`. This is the company-wise switch from spec R3.
- `amount` is resolved **server-side** at creation and never accepted from the client:
  `fixed → requested_value` · `percentage → round2(basis_gross * requested_value / 100)`.
  `basis_gross` and `employee_salary_id` are copied from the employee's active salary as of the request date and are never recomputed afterwards.
- **The ceiling is checked on the month's total, not on the single request.** 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.
- `submit` behaviour depends on policy: `advance_requires_approval = false` approves immediately and stamps `approved_by`; otherwise it submits through `ApprovalGateway::submit()` with `correlationId = "salary_advance:{id}"` and entity type `salary_advance`.
- `record-payment` requires status `approved`. **HR pays the money by hand — cash, cheque, or a manual transfer — and this endpoint only records that it happened** (spec R3). 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`.
- **Only `paid` advances are netted off by the payslip generator.** 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`.
- An advance whose `(month, year)` 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.
- 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.
- `settled` is set by the payroll run executor (8.3a-BE), never by this story's endpoints.
- The `summary` endpoint returns, for an employee and period, the month's 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.

### Calculation Pseudocode
```
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 <= 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 > round2(basis * settings.advance_max_percentage / 100):
        throw Unprocessable("exceeds percentage ceiling", headroom: ...)
    if settings.advance_max_amount and taken + amount > 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(ApprovalSubmissionData(
        moduleSlug: "payroll", actionSlug: "advance-approve",
        entityType: "salary_advance", entityId: advance.id,
        correlationId: "salary_advance:" + advance.id,
        title: "Salary advance - " + employeeName + ", " + amount + " for " + month + "/" + year,
        onApproved: fn() => 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)
```

### Validation Rules
- `employee_id`, `month`, `year`, `request_method`, `requested_value` — required.
- `requested_value` — greater than 0; at most 100 when `request_method` is `percentage`.
- `month` 1–12; `year` within one year either side of the current year.
- `amount`, `basis_gross`, `employee_salary_id`, `status`, `settled_*` — server-set, rejected if present in the request body.
- `payment_channel` — required on `record-payment`; `payment_reference` required when it is `cheque` or `bank`.
- `paid_at` — not in the future.
- `PUT` may change `requested_value`, `request_method`, and `reason` only, and only from `draft`; the amount is re-resolved and the ceiling re-checked.

### Error Handling
- `advance_enabled = false` on any endpoint → **409**.
- Month total would exceed either ceiling → **422**, with the remaining headroom in the payload so the caller can retry with a valid figure.
- Employee has no active salary → **422**.
- `record-payment` from a status other than `approved` → **409**.
- `cheque`/`bank` handover without a reference → **422**.
- Cancelling a `paid` or `settled` advance → **409**.
- Any write against a period whose run is `approved` or later → **409** naming the run id.

### Acceptance Criteria
- An employee on a 30,000 gross with a 50% advance produces a row with `amount = 15,000` and `basis_gross = 30,000`.
- A second request of 10% in the same month is rejected as over the 50% ceiling, and the error states the remaining headroom.
- Raising the employee's salary to 40,000 after the request leaves `basis_gross` and `amount` unchanged.
- With `advance_requires_approval = false`, `submit` returns an already-approved advance and writes no `approval_requests` row.
- With it true, the advance sits at `pending_approval` until the workflow completes, then the executor approves it exactly once.
- `record-payment` with channel `cash` moves the row to `paid`; with channel `cheque` and no reference it returns 422.
- An `approved`-but-unpaid advance does **not** appear on that month's payslip; recording its payment and regenerating makes it appear.
- Cancelling a `paid` advance returns 409.
- Creating an advance for a month whose payroll run is already approved returns 409 naming the run.

### Definition of Done
Platform DoD plus:
- Migration for `salary_advances`.
- `SalaryAdvanceExecutor` implemented and registered as `salary_advance` (stub created in 4.1-BE).
- 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.
- Test proving `basis_gross` survives a later salary revision.
- Both approval paths (policy bypass and workflow) covered.
- `SalaryAdvancePaid` and `SalaryAdvanceApproved` events emitted with no listeners yet, per spec §10.
- `api collection/Payroll/Salary Advances/*.yml`.

---

## TASK 7.5-FE: Salary Advances — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 7.5-BE Salary Advances
**Permission:** `payroll.advance-manage`
**Menu:** **Payroll › Salary Advances** — the whole item is hidden when `payroll_settings.advance_enabled = false`
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `salary_advances`, `payroll_settings` — see 7.5-BE and 7.0-BE.

### Frontend Routes
```
/payroll/salary-advances
/payroll/salary-advances/{id}
```

### Main Screen Sections
- **Advance list** — 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.
- **Request drawer** — 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's gross, the month's advances so far, and the **remaining headroom** under each ceiling, all from the `summary` endpoint.
- **Record-payment dialog** — channel (cash / cheque / bank / mobile banking), reference, and payment date. The reference field becomes required for cheque and bank.
- **Detail view** — the request, its approval trail via the shared `<ApprovalStatusBadge />`, the handover record, and once settled, a link to the payslip that netted it off.
- **Payslip integration** — the "Advance already paid" line between net pay and net payable on the payslip view (8.2-FE), linking back here.

### API Integration
```
GET   /api/v1/payroll/salary-advances?employee_id=&month=&year=&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=&month=&year=
GET   /api/v1/payroll/settings
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **New advance** | `advance_enabled` **and** `payroll.advance-manage` | — | Request drawer opens | Whole nav item is hidden when advances are disabled |
| 2 | Pick employee and period | in the drawer | `GET /salary-advances/summary` | Gross, month total so far, and **remaining headroom** under each ceiling | 422 no active salary → inline, Save disabled |
| 3 | Toggle fixed / percentage | in the drawer | — | Resolved taka amount stays visible at all times | — |
| 4 | **Save draft** | amount ≤ headroom (capped in the field) | `POST /salary-advances` | Status `draft` | 422 over ceiling → field error carrying the headroom |
| 5 | **Submit** | status = `draft` | `POST /{id}/submit` | `pending_approval`, **or** straight to `approved` when the company policy bypasses approval | 409 period closed → read-only with a link to the run |
| 6 | **Record payment** | status = `approved` | `POST /{id}/record-payment` | `paid`; dialog states it records a handover HR already made and moves no money | 422 cheque or bank without a reference |
| 7 | **Cancel** | status ∈ `draft`, `pending_approval`, `approved` | `POST /{id}/cancel` | `cancelled` | On a `paid` advance the control is shown **disabled** with a tooltip — the money is gone, it must settle or carry forward |
| 8 | Open a settled advance | status = `settled` | — | Links to the payslip that netted it off | — |

### UI Rules
- **The nav item and the routes are hidden entirely when `advance_enabled` is false.** The switch is read once from `GET /payroll/settings`; a user in a company that does not offer advances never sees the feature.
- The headroom figure is the screen's 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.
- 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.
- 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.
- The record-payment dialog states plainly that it records a handover HR has already made and does not move any money.
- An advance for a closed period renders read-only with the reason and a link to the payroll run, instead of failing on save.
- Amounts use the company currency formatter throughout; percent and taka are never shown in the same column without a unit.

### Acceptance Criteria
- With advances disabled, no nav item, no route, and a direct URL redirects.
- Typing 60 into the percentage field when the ceiling is 50 is prevented in the form, with the headroom shown.
- The resolved taka amount is visible before the request is saved.
- A cheque handover cannot be recorded without a reference.
- A settled advance links to the payslip that netted it.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/salaryAdvanceApi.ts`.
- Nav entry under Payroll, gated on both `payroll.advance-manage` and `advance_enabled`.
- Currency formatting reuses the existing shared formatter; no local money helper.

---

## TASK 7.6-BE: Payment Mode Split — Backend

**Task Type:** Backend · **Part:** G — Payroll configuration · **Module:** Payroll
**Start after:** 0.1-BE Module Scaffolding
**Also needs (can be stubbed):** 7.1-BE Salary Structures
**Permission:** `payroll.payment-mode-manage`
**Menu:** none — API only; surfaces in 7.6-FE
**Points:** 5

### Task Summary
*As HR, I want to say at salary-assignment time how much of an employee's 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.*

### Related Tables
- `employee_salary_payment_modes` (new)
- `employee_salaries`, `employee_bank_accounts` — existing, Employee module
- `payslip_payment_allocations` — the frozen result, created in 8.2b-BE

### Related DB Schema
**employee_salary_payment_modes**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `employee_salary_id` | unsignedBigInteger | `employee_salaries.id` — the split is versioned with the salary revision |
| `channel` | enum(`cash`,`cheque`,`bank`,`mobile_banking`) | Where this slice is paid |
| `allocation_type` | enum(`fixed`,`percentage`,`residual`) | How the slice is sized |
| `value` | decimal(12,2) nullable | Amount, or percent; **null when `residual`** |
| `employee_bank_account_id` | unsignedBigInteger nullable | Required for `bank` and `mobile_banking` |
| `display_order` | int, default 0 | Order of application |
| `created_by` / `updated_by` | unsignedBigInteger nullable | Audit columns |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `index(company_id, employee_salary_id)`, `unique(employee_salary_id, channel)`

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.

### API Endpoints
```
GET /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes
PUT /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes
```

There is no per-row create, update, or delete endpoint. The rows are only valid as a **set** — exactly one residual, bounded totals — so the `PUT` replaces the whole set in one transaction.

### Business Rules
- **Exactly one `residual` row is mandatory.** 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.
- `value` is required and greater than 0 for `fixed` and `percentage`, and must be null for `residual`.
- Percentages are in `(0, 100]`. They are **applied to the payslip's net payable, not to gross** (see 8.2b-BE); configuration is entered against gross only because that is the figure HR knows.
- `employee_bank_account_id` is required for `bank` and `mobile_banking`, must belong to the same employee, and must be active.
- At most one row per channel, enforced by the unique key — an employee does not get two separate cash lines.
- Fixed plus percentage rows totalling more than the salary's `gross_salary` returns a **soft warning** in the response rather than a block, since the real net is lower anyway and the allocator caps each line at what remains.
- **An empty set is valid** and means "everything to the primary bank account". This is the pre-existing behaviour, so employees configured before this story keep working untouched.
- Editing the set does not touch already-generated payslips; their allocations were frozen at generation (8.2b-BE).
- `display_order` decides the order slices are taken; the residual row is always applied last regardless of its order value.

### Validation Rules
- `modes` — array, may be empty; each entry requires `channel` and `allocation_type`.
- Exactly one entry with `allocation_type = residual` when the array is non-empty → otherwise **422**.
- `value` — required and greater than 0 for `fixed`/`percentage`; must be absent or null for `residual`.
- `value` at most 100 for `percentage`.
- `employee_bank_account_id` — required for `bank`/`mobile_banking`, must exist, must belong to the employee, must be active.
- Duplicate `channel` within the payload → **422** (checked before the unique key fires).
- The target `employee_salaries` row must belong to the caller's company and must not be superseded by a newer active revision.

### Error Handling
- Zero or two or more residual rows → **422** naming the rule.
- Bank channel with no account, or an account belonging to another employee → **422**.
- Duplicate channel in the payload → **422**.
- Editing the modes of a salary revision that has already produced a finalized payslip → allowed, with a **200 plus warning** stating that existing payslips are unaffected and the change applies from the next generation.

### Acceptance Criteria
- Saving cash 5,000 fixed · cheque 10% · bank residual on a 30,000 salary succeeds and reads back in `display_order`.
- Saving the same set with two residual rows returns 422.
- Saving a bank row without an account returns 422.
- Saving a bank row with another employee's account returns 422.
- Saving an empty set succeeds and the employee falls back to their primary account at allocation time.
- A second `PUT` fully replaces the earlier set, leaving no orphan rows.
- Assigning a new salary to the employee leaves the previous revision's modes intact and untouched.

### Definition of Done
Platform DoD plus:
- Migration for `employee_salary_payment_modes`.
- The set-level validation lives in one Form Request rule object, reused by 8.2b-BE's pre-generation assertion.
- Transactional replace tested for orphans.
- `api collection/Payroll/Payment Modes/*.yml`.

---

## TASK 7.6-FE: Payment Mode Split — Frontend

**Task Type:** Frontend · **Part:** G — Payroll configuration · **Module:** Payroll (component hosted in Employee)
**Start after:** 7.6-BE Payment Mode Split
**Permission:** `payroll.payment-mode-manage`
**Menu:** **Employees › Salary tab** — a Payroll-owned panel hosted by the Employee module; **no Payroll nav item**
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `employee_salary_payment_modes` — see 7.6-BE.

### Frontend Routes
```
No new route. The panel mounts inside the existing Employee salary assignment form:
/employee/employees/{id}  →  Salary tab  →  "Payment split" panel
```

**Ownership:** the component, its API client, and its validation live in `modules/payroll` and are **imported** 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 7.1.

### Main Screen Sections
- **Payment split panel** — a repeatable row builder: channel, allocation type, value, bank account. Rows reorder by drag.
- **Residual marker** — the residual row renders visually distinct and pinned last, labelled "everything remaining", with its value field disabled.
- **Live preview** — a sample net payable (defaulting to the salary's gross, editable) run through the same allocation rules as 8.2b-BE, showing the taka each channel would receive and the total.
- **Empty state** — "All pay goes to the primary bank account", with an Add split button.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Add split** | `payroll.payment-mode-manage`, empty state | — | First row **plus an auto-created residual row**, so a valid set is the default | — |
| 2 | Pick channel | per row | `GET /employee/employees/{id}/bank-accounts` | Bank account picker appears only for bank / mobile banking, listing that employee's active accounts | — |
| 3 | Enter value | fixed or percentage rows | — | Live preview recomputes; a fixed line visibly **capped** when the sample net is lowered below it | — |
| 4 | Drag to reorder | non-residual rows | — | Preview order changes; residual always applies last | — |
| 5 | Delete last non-residual row | — | — | Whole set clears back to the empty state | Residual row offers no delete |
| 6 | **Save** | `payment-mode-manage` | `PUT /employee-salaries/{id}/payment-modes` | Set replaced in one transaction | 422 two residual rows, duplicate channel, or bank row without an account |
| 7 | Save with fixed + % over gross | — | same | Saves, with an **inline non-blocking warning** — the real net is lower anyway | — |

### UI Rules
- 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.
- Deleting the residual row is not offered; deleting the last non-residual row clears the whole set back to the empty state.
- The bank-account picker appears only for `bank` and `mobile_banking`, and lists only that employee's active accounts, with the primary marked.
- The percentage field shows a `%` adornment and the fixed field shows the currency symbol — the two are never visually interchangeable.
- The preview is the panel's 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.
- A soft warning from the API (fixed + percentage over gross) renders inline and does not block saving.
- The panel is read-only, with an explanatory note, for users holding the Employee salary permission but not `payroll.payment-mode-manage`.

### Acceptance Criteria
- HR sets the split without leaving the salary form.
- The preview totals exactly the sample net payable in every configuration, including when a fixed line is capped.
- A residual row always exists once any split row does, and cannot be deleted or valued.
- The bank account picker never offers another employee's account.
- Without the payment-mode permission the panel renders but does not save.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/paymentModeApi.ts` and `modules/payroll/components/PaymentSplitPanel.tsx`.
- The panel imported by the Employee salary form with no Payroll-specific logic leaking into the Employee module.
- The preview reuses one allocation helper shared with 8.2-FE, not a second implementation of the rules.

---

# Part H — Payroll Execution

Cards appear in build order. **8.0 is deliberately built after 8.1**, because `attendance_snapshots` carries a `payroll_run_id` and cannot exist before payroll runs do — the source document had these the other way round.

## TASK 8.1-BE: Payroll Run Creation — Backend

**Task Type:** Backend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 6.2-BE Monthly Freeze
**Also needs (can be stubbed):** 7.0-BE Payroll Settings · 7.1-BE Salary Structures
**Permission:** `payroll.run-create` · `payroll.run-override-readiness`
**Menu:** none — API only; surfaces in 8.1-FE
**Points:** 5

### Task Summary
*As Finance, I want to create a monthly payroll run that pulls in every eligible employee, so payslips can be generated as one batch.*

### Related Tables
- `payroll_runs` (new)

### Related DB Schema
**payroll_runs**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `month` / `year` | tinyint / smallint | Payroll period |
| `run_type` | enum(`regular`,`off_cycle`), default `regular` | Allows a correction run alongside the regular one |
| `status` | enum(`draft`,`processing`,`pending_approval`,`approved`,`paid`,`locked`,`failed`) | Lifecycle |
| `total_employees` | int, default 0 | Employees included |
| `total_amount` | decimal(15,2), default 0 | Sum of net pay |
| `processed_at` | datetime nullable | When generation completed |
| `approved_by` | unsignedBigInteger nullable | Approver |
| `created_by` | unsignedBigInteger nullable | Creator |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(company_id, month, year, run_type)` · `index(company_id, status)`

### API Endpoints
```
GET  /api/v1/payroll/payroll-runs?month=&year=&status=
POST /api/v1/payroll/payroll-runs
GET  /api/v1/payroll/payroll-runs/{id}
GET  /api/v1/payroll/payroll-runs/{id}/readiness
```

`readiness` reports which employees are and are not ready, without creating anything.

### Business Rules
- A run is created for a month and year, including only employees whose monthly attendance is `ready_for_payroll` (Story 6.1).
- 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.
- One run per company per month, year, and run type.
- Status transitions are enforced: `draft → processing → pending_approval → approved → paid → locked`, with `failed` reachable only from `processing`. Any other transition is rejected.
- `total_employees` and `total_amount` are recomputed as payslips are generated (Story 8.2).
- A run cannot move to `paid` without having been `approved`.

### Calculation Pseudocode
```
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
```

### Validation Rules
- `month` 1–12; `year` within ±5 of the current year.
- `run_type` in the enum.
- `override` honoured only with `payroll.run-override-readiness`.

### Error Handling
- Duplicate run for the period → **409**.
- Unready employees without override → **422**, body lists employee ids and the blocking reason each.
- Invalid status transition → **409** naming the current and attempted status.

### Acceptance Criteria
- Finance can create a run once every included employee's month is approved and frozen.
- Creating a second regular run for the same period is blocked; an off-cycle run for the same period is allowed.
- The readiness endpoint lists unready employees with reasons before anything is created.
- Overriding readiness excludes the unready employees and records an audit entry.
- The run's employee count and total update as payslips are generated.
- A run cannot be marked paid without prior approval.

### Definition of Done
Platform DoD plus:
- Status machine implemented as an explicit transition map and unit-tested for every illegal transition.
- Readiness check shares one code path with creation.
- `api collection/Payroll/Payroll Runs/*.yml`.

---

## TASK 8.1-FE: Payroll Run Creation — Frontend

**Task Type:** Frontend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.1-BE Payroll Run Creation
**Also needs (can be stubbed):** 6.2-FE Monthly Freeze
**Permission:** `payroll.run-create` · `payroll.run-override-readiness`
**Menu:** **Payroll › Payroll Runs**
**Points:** 3

### Task Summary
*As Finance, I want to see whether a month is ready before I create its run, and to understand exactly who is blocking it.*

### Related Tables
- `payroll_runs` — see 8.1-BE.

### Frontend Routes
```
/payroll/payroll-runs
/payroll/payroll-runs/{id}
```

### Main Screen Sections
- **Run list** — period, run type, status, employee count, total amount.
- **Create run flow** — period picker, then a readiness report listing ready and unready employees before the confirm step.
- **Run detail** — status timeline, included employees, and links into payslips and disbursement.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Pick a period | `payroll.run-create` | `GET /payroll-runs/{id}/readiness` | Ready and unready employees listed **before** Create becomes available | — |
| 2 | Unready row → **Fix** | always | — | Navigates to that employee's monthly approval row | — |
| 3 | **Create run** | every employee ready | `POST /payroll-runs` | Run created in `draft` | 409 duplicate run for the period |
| 4 | **Create with override** | `run-override-readiness` | same, `override: true` | Unready employees excluded; audit entry written | Option is off by default and states how many will be excluded |
| 5 | Open run detail | always | `GET /payroll-runs/{id}` | Status timeline with the current state highlighted | — |

**Deliberately ordered:** readiness is fetched and shown *before* Create is offered. Creating first and reading the failure afterwards is the wrong order for a batch action.

### UI Rules
- Readiness is checked and shown **before** the create button becomes available — creating and then reading a failure is the wrong order for a batch action.
- Each unready employee links to their monthly approval row, so the blocker can be cleared in one hop.
- The override option is visible only to holders of the permission, is off by default, and states how many employees will be excluded.
- The status timeline shows the full lifecycle with the current state highlighted, so nobody has to guess what comes next.
- Off-cycle runs are visually distinguished from regular runs in the list.

### Acceptance Criteria
- Finance sees the readiness report before committing to a run.
- Every unready employee is one click from the screen that fixes them.
- The override, when used, states its consequence up front.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/payrollRunApi.ts`.
- Reuses `<MonthStateBadge />` from 6.2-FE; nav entry under Payroll.

---

## TASK 8.0-BE: Attendance Snapshot for Payroll — Backend

**Task Type:** Backend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.1-BE Payroll Run Creation · 6.2-BE Monthly Freeze
**Permission:** `payroll.run-create`
**Menu:** none — API only; surfaces in 8.0-FE
**Points:** 5

### Task Summary
*As the system, I want an immutable snapshot of each employee's attendance captured when a payroll run is built, so payroll stays reproducible even if attendance is corrected afterwards.*

### Related Tables
- `attendance_snapshots` (new)
- `monthly_attendance_approvals`, `payroll_runs` (existing)

### Related DB Schema
**attendance_snapshots**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `payroll_run_id` | unsignedBigInteger | Which run this snapshot feeds |
| `month` / `year` | tinyint / smallint | Period covered |
| `present_days` | decimal(7,2) | Copied from the frozen monthly summary |
| `absent_days` | decimal(7,2) | Copied |
| `leave_days` | decimal(7,2) | Copied |
| `unpaid_leave_days` | decimal(7,2) | Copied — drives pro-rating |
| `half_days` | decimal(7,2) | Copied |
| `late_count` | int | Copied |
| `overtime_hours` | decimal(7,2) | Copied |
| `working_hours` | decimal(7,2) | Copied |
| `source_monthly_approval_id` | unsignedBigInteger | The frozen `monthly_attendance_approvals` row |
| `snapshot_taken_at` | timestamp | When this snapshot was built |

Keys: `unique(payroll_run_id, employee_id)` · `index(company_id, month, year)`

**Immutable.** No `updated_at`, no update endpoint, no delete endpoint.

### API Endpoints
```
POST /api/v1/payroll/payroll-runs/{id}/build-snapshots
GET  /api/v1/payroll/attendance-snapshots?payroll_run_id=&employee_id=
GET  /api/v1/payroll/attendance-snapshots/{id}
GET  /api/v1/payroll/attendance-snapshots/{id}/divergence
```

`divergence` compares the snapshot against current live attendance and reports differences — diagnostic only, it changes nothing.

### Business Rules
- One snapshot per employee per payroll run, copied from the **frozen** `monthly_attendance_approvals` row.
- A snapshot cannot be built from a month that is not frozen (Story 6.2).
- Snapshots are never updated in place. A reprocessed or off-cycle run creates a new, separate set of snapshots.
- **The payslip generator reads only from `attendance_snapshots`** — never from live `attendance_records` or `monthly_attendance_approvals`. This is the property that makes an old payslip explainable months later.
- Building is idempotent per run: re-running skips employees who already have a snapshot rather than duplicating or overwriting.

### Validation Rules
- Target run must be in `draft` status.
- Every included employee must have a frozen monthly approval for the run's period.

### Error Handling
- Building from a month that is not frozen → **409** listing the employees whose months are unfrozen.
- Missing source monthly approval → **404** naming the employee.
- Building on a run past `draft` → **409**.

### Acceptance Criteria
- Snapshot values exactly match the frozen monthly summary at the moment of freeze.
- Correcting attendance after freeze leaves every existing snapshot unchanged.
- A new off-cycle run produces a new snapshot set rather than mutating the old one.
- Re-running the build on the same run creates no duplicates.
- 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.
- The divergence endpoint reports a difference after a post-freeze correction, without altering the snapshot.

### Definition of Done
Platform DoD plus:
- Migration with the unique key.
- A test that mutates live attendance after snapshotting and asserts payslip output is unchanged.
- Grep-level check in review that the payroll code path contains no query against `attendance_records`.
- `api collection/Payroll/Attendance Snapshots/*.yml`.

---

## TASK 8.0-FE: Attendance Snapshot for Payroll — Frontend

**Task Type:** Frontend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.0-BE Attendance Snapshot
**Also needs (can be stubbed):** 8.1-FE Payroll Run Creation
**Permission:** `payroll.run-create` · `payroll.payslip-view-all`
**Menu:** **Payroll › Payroll Runs** — embedded on the run detail (8.1-FE), no own nav item
**Points:** 2

### Task Summary
*As Finance, I want to see exactly what attendance data a payslip was generated from, even months later, so payroll figures are always explainable.*

### Related Tables
- `attendance_snapshots` — see 8.0-BE.

### Frontend Routes
```
/payroll/payroll-runs/{id}/snapshots
/payroll/payroll-runs/{id}/snapshots/{employeeId}
```

### Main Screen Sections
- **Snapshot list** — per run: employee, key attendance totals, snapshot timestamp.
- **Snapshot detail** — every captured field, read-only.
- **Divergence indicator** — flags rows where live attendance has since changed, with a side-by-side comparison on the detail view.
- **Build snapshots action** — on a draft run, with a pre-flight list of employees whose months are not yet frozen.

### API Integration
```
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
```

### UI Rules
- No edit control exists anywhere on these screens — the read-only nature is the feature, and offering a disabled edit button would misrepresent it.
- The divergence indicator is explicitly labelled as diagnostic, with a note that the payslip used the snapshot values.
- The build action is unavailable once the run leaves `draft`, with the reason stated.
- The pre-flight list links each unfrozen month to the freeze screen.

### Acceptance Criteria
- Finance can open any past run and see precisely what attendance produced each payslip.
- A divergence between snapshot and live attendance is visible and clearly marked as informational.
- No control on these screens can modify a snapshot.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/attendanceSnapshotApi.ts`.
- Linked from the payroll run detail view.

---

## TASK 8.2a-BE: Payslip Generation — Backend

**Task Type:** Backend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.0-BE Attendance Snapshot
**Also needs (can be stubbed):** 7.0-BE Payroll Settings · 7.2-BE Structure Components · 7.3-BE Tax Slabs · 7.4-BE Deductions & Loans
**Permission:** `payroll.run-create` · `payroll.payslip-view-own` / `-all`
**Menu:** none — API only; surfaces in 8.2-FE
**Points:** 8

> Split from the original 13-point 8.2-BE. This card owns the `payslips` table and the earnings → deductions → tax → loans → net pipeline. **8.2b-BE** adds salary-advance settlement and the cash/cheque/bank split.
>
> **Hard ordering constraint:** this card writes `advance_paid = 0` and `net_payable = net_pay` as a provisional value. That is correct only while no advances have been recorded. **8.2b-BE must be merged before 8.3b-BE (disbursement)**, or a company using advances would be paid the full net twice. State this on the ticket.

### Task Summary
*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.*

### Related Tables
- `payslips` (new)

### Related DB Schema
**payslips**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `payroll_run_id` | unsignedBigInteger | Which run this belongs to |
| `attendance_snapshot_id` | unsignedBigInteger | **`attendance_snapshots.id`** — the frozen data used |
| `employee_salary_id` | unsignedBigInteger | Which `employee_salaries` row was used |
| `gross_earnings` | decimal(12,2) | Sum of earning components |
| `total_deductions` | decimal(12,2) | Sum of deduction components |
| `net_pay` | decimal(12,2) | gross − deductions, floored at 0 — the employee's **earned** net |
| `advance_paid` | decimal(12,2), default 0 | Written by 8.2b-BE; **always 0 from this card** |
| `net_payable` | decimal(12,2) | Written by 8.2b-BE; **equals `net_pay` from this card** |
| `advance_carry_forward` | decimal(12,2), default 0 | Written by 8.2b-BE; always 0 from this card |
| `earnings_breakdown` | json | Per-component amounts, keyed by `component_code` |
| `deductions_breakdown` | json | Per-component amounts |
| `currency_code` | char(3) | From the employee's salary row |
| `status` | enum(`draft`,`finalized`,`paid`) | Lifecycle |
| `needs_review` | boolean, default false | Net pay would have gone negative, or an installment was skipped |
| `generated_at` | timestamp | When generated |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(payroll_run_id, employee_id)` · `index(company_id, employee_id)`

The three advance columns ship in **this** card's migration even though only 8.2b-BE populates them, so 8.2b needs no schema change and the two cards cannot deadlock on migration order.

The source document described `attendance_snapshot_id` as referencing `monthly_attendance_approvals.id` while also mandating snapshot-only reads. It references **`attendance_snapshots.id`**.

`net_pay` keeps its original meaning — the **earned** net for the month. Tax certificates, salary reports, and year-end statements read `net_pay`, never `net_payable`.

### API Endpoints
```
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=&year=
POST /api/v1/payroll/payslips/{id}/regenerate
GET  /api/v1/payroll/payslips/{id}/download
```

### Business Rules
- Generation is **queued and chunked** 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.
- Generation reads attendance **only** from `attendance_snapshots`.
- The active salary is the `employee_salaries` row with the latest `effective_date` not after the run's period end and `status = Active`. Its id is stored on the payslip.
- When `employee_salaries.basic_salary` is set, it is used as-is for the `is_basic` component instead of the structure's calculated value.
- Components with `prorated = false` keep their full value regardless of unpaid days.
- Overtime is paid only when `employee_salaries.overtime_eligible = true`, using the shared `hourlyRate()` from Story 7.0.
- Income tax is skipped entirely when `employee_tax_profiles.tax_exemption = 1` **or** `employee_salaries.tax_applicable = 0`.
- The taxable base is the sum of earning components with `is_taxable = true`, annualised by ×12 — not the sum of all earnings.
- Recurring deductions are applied through the idempotent path from Story 7.4.
- Negative net pay is floored at zero and the payslip is flagged `needs_review` rather than silently zeroed.
- Only `draft` payslips can be regenerated; regeneration first reverses that run's deduction entries. A payslip is locked once its run is approved.
- Employees may read their own payslips with `payroll.payslip-view-own`.
- **The generator ends with a single extension point**, `PayslipFinaliser`, shipped here as a pass-through that sets `advance_paid = 0` and `net_payable = net_pay`. 8.2b-BE replaces its body. This is what keeps the two cards from touching the same code twice.

### Calculation Pseudocode
```
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 > 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))   # Story 7.4
        if amt > 0: deductions[d.type] = amt

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

    if net_pay < 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()
```

### Validation Rules
- The run must be in `draft` or `processing`.
- 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.
- An active tax slab set for the run's year is required unless every employee is tax-exempt.

### Error Handling
- No snapshot for an employee → that employee fails with a reason; the run continues.
- No active tax slab set for the year, with taxable employees present → **422** before generation starts.
- Regenerating a `finalized` or `paid` payslip → **409**.
- Reading another employee's payslip with only `payslip-view-own` → **403**.

### Acceptance Criteria
- Generating a run produces one payslip per eligible employee with correct gross, deductions, and net.
- An employee with unpaid leave shows a proportionally reduced gross, while their non-prorated allowance is unchanged.
- An employee with `overtime_eligible = false` receives no overtime line, even with overtime hours in the snapshot.
- A tax-exempt employee has no `income_tax` line at any income level.
- The taxable base excludes non-taxable components, verified against a structure containing one of each.
- Each breakdown sums exactly to `gross_earnings` and `total_deductions`.
- Regenerating a draft three times leaves the loan balance decremented exactly once.
- A negative-net payslip is written with `net_pay = 0` and `needs_review = true`.
- Generation of 500 employees completes through the queue with progress reported.
- Every generated payslip has `net_payable == net_pay` and `advance_paid == 0` — the documented provisional state until 8.2b-BE lands.

### Definition of Done
Platform DoD plus:
- Migration for `payslips`, **including** the three advance columns.
- Every rule above covered by a unit test with an explicit worked example.
- One integration test generating a full run against fixture data, asserting the totals by hand.
- Chunked queued job with a progress endpoint and per-employee failure reporting.
- `PayslipFinaliser` extracted as an injected service with a pass-through implementation and a test asserting the provisional values.
- PDF rendering for `download`.
- `api collection/Payroll/Payslips/*.yml`.

---

## TASK 8.2b-BE: Advance Settlement & Payment Allocation — Backend

**Task Type:** Backend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.2a-BE Payslip Generation
**Also needs (can be stubbed):** 7.5-BE Salary Advances · 7.6-BE Payment Mode Split
**Permission:** `payroll.run-create`
**Menu:** none — API only; surfaces in 8.2-FE as the settlement block and the channel split panel
**Points:** 5

> Split from the original 13-point 8.2-BE. Replaces the pass-through `PayslipFinaliser` from 8.2a-BE with the real body. **Must merge before 8.3b-BE.**

### Task Summary
*As Finance, I want any salary advance already handed over netted off the payslip, and the remainder split across the employee's cash, cheque, and bank channels and frozen, so disbursement knows exactly where each taka goes.*

### Related Tables
- `payslip_payment_allocations` (new)
- `payslips` — columns already exist from 8.2a-BE; this card populates `advance_paid`, `net_payable`, `advance_carry_forward`
- `salary_advances` — read for settlement, see 7.5-BE
- `employee_salary_payment_modes` — read for the split, see 7.6-BE

### Related DB Schema
**payslip_payment_allocations**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `payslip_id` | unsignedBigInteger | Parent payslip |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `channel` | enum(`cash`,`cheque`,`bank`,`mobile_banking`) | Where this slice goes |
| `amount` | decimal(12,2) | Slice amount |
| `employee_bank_account_id` | unsignedBigInteger nullable | Null for cash and cheque |
| `source` | enum(`config`,`manual_override`), default `config` | Where the split came from |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

Keys: `unique(payslip_id, channel)` · `index(company_id, payslip_id)`

The **resolved** split, frozen at generation for the same reason `attendance_snapshots` is frozen: changing an employee's payment split in October must not silently rewrite what July's payslip says was paid. Disbursement (8.3b-BE) reads only from here.

### API Endpoints
```
GET /api/v1/payroll/payslips/{id}/allocations
```

Everything else runs inside the existing generation and regeneration endpoints from 8.2a-BE. No new write endpoint — allocations are derived, never hand-entered.

### Business Rules

**Salary advance settlement (spec R3, §6.3):**
- Advances are **not deductions**. Tax, percentage components, and loan installments were all computed on the full gross in 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.
- **Only advances in status `paid` are netted.** 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`.
- `advance_paid` is the sum of those rows for `(employee, run.month, run.year)`; `net_payable = max(0, net_pay − advance_paid)`.
- 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`.
- **Nothing on `salary_advances` is mutated during draft generation.** Settlement stamping (`paid → settled`) and raising the carry-forward `employee_deductions` row both happen on run **approval**, in 8.3a-BE, so that draft regeneration stays repeatable.
- The run's `total_amount` becomes the sum of **`net_payable`**, not `net_pay` — it is the money the company still has to move. 8.2a-BE's total is corrected by this card.

**Payment allocation (spec R4, §6.4):**
- `PaymentAllocator` splits `net_payable` across the employee's configured channels and writes `payslip_payment_allocations`.
- Percentages apply to **`net_payable`, not to gross**. Configuration is entered against gross because that is the figure HR knows, but only the payable money can actually be split.
- 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.
- 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.
- An employee with no configured modes falls back to one residual line on their primary bank account — the pre-existing behaviour.
- `net_payable = 0` produces no allocation rows and no disbursement items. The payslip is still generated and viewable.
- Regeneration of a draft replaces the allocations wholesale; the advances themselves are re-read, never mutated.
- The payslip PDF from 8.2a-BE gains three lines — net pay, advance already paid, net payable — plus the channel breakdown.

### Calculation Pseudocode
```
class PayslipFinaliser:                  # replaces the 8.2a-BE pass-through
    function finalise(payslip, salary):

        # ---- salary advance settlement (Story 7.5) ----
        # 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 > 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 > 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 > 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)
```

**Worked example** — 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:

| | |
|---|---|
| `gross_earnings` | 30,000.00 |
| `total_deductions` | 3,500.00 |
| `net_pay` | **26,500.00** — earned; what tax and reports use |
| `advance_paid` | 15,000.00 |
| `net_payable` | **11,500.00** — what disbursement pays |
| allocations | cash 5,000.00 · cheque 1,150.00 · bank 5,350.00 = 11,500.00 |

### Validation Rules
- 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.
- The payment-mode set is re-validated with the same rule object as 7.6-BE before allocation; an invalid set fails that employee only.

### Error Handling
- Allocations not summing to `net_payable` → **500** with the transaction rolled back. This is an invariant violation, not a user error.
- A `bank`/`mobile_banking` allocation above zero with no valid account → that employee fails with a reason; the run continues.

### Acceptance Criteria
- 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.
- Income tax on that employee is computed on 26,500-worth of earnings, **not** on 11,500 — asserted directly, since this is the whole point of treating an advance as a payment.
- An `approved`-but-unpaid advance leaves `advance_paid` at 0; recording its payment and regenerating brings it to 15,000.
- An employee whose paid advance exceeds their earned net gets `net_payable = 0`, a non-zero `advance_carry_forward`, and `needs_review = true`.
- Draft generation mutates no `salary_advances` row; regenerating three times leaves them all still `paid`.
- Allocations sum exactly to `net_payable` in every case, including when a fixed cash line is capped by a low net.
- An employee with no configured payment modes gets a single allocation to their primary account.
- Changing an employee's payment split after generation does not alter an already-generated payslip's allocations.
- An employee with no advance and no configured split ends with exactly the same payslip figures 8.2a-BE produced — a regression test proving this card is additive.
- The run's `total_amount` equals the sum of `net_payable` after this card, not the sum of `net_pay`.

### Definition of Done
Platform DoD plus:
- Migration for `payslip_payment_allocations`. **No change to `payslips` — its columns shipped in 8.2a-BE.**
- `PaymentAllocator` implemented as its own testable service, not inlined in the finaliser, and shared with 7.6-FE's preview through a documented contract.
- Property-style test asserting `sum(allocations) == net_payable` across a matrix of splits and net values, including zero.
- Payslip PDF extended with the settlement lines and channel breakdown.
- Regression test asserting no-advance / no-split payslips are unchanged from 8.2a-BE.
- `api collection/Payroll/Payslips/allocations-*.yml`.

---

## TASK 8.2-FE: Payslip Generation — Frontend

**Task Type:** Frontend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.2a-BE Payslip Generation
**Also needs (can be stubbed):** 8.2b-BE Advance Settlement & Allocation
**Permission:** `payroll.run-create` · `payroll.payslip-view-own` / `-all`
**Menu:** **Payroll › Payroll Runs** (payslip list inside the run) **and Payroll › My Payslips** (self-service) — this card builds two surfaces
**Points:** 8

### Task Summary
*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.*

### Related Tables
- `payslips` — see 8.2a-BE · `payslip_payment_allocations` — see 8.2b-BE.

### Frontend Routes
```
/payroll/payroll-runs/{id}/payslips
/payroll/payslips/{id}
/payroll/my-payslips
```

### Main Screen Sections
- **Generate action** — on a draft run; switches to a progress view with a per-employee failure list.
- **Payslip list within a run** — 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.
- **Payslip detail** — full earnings and deductions breakdown, then a distinct **settlement block**: net pay → less advance already paid → net payable, with the advance line linking to its record in 7.5-FE. Below it, the **payment split panel** showing what each channel receives. Plus the attendance figures used (linked to the snapshot), the salary row used, and print/download.
- **My payslips** — employee self-service list and detail for their own payslips only, carrying the same settlement block and split panel.

### API Integration
```
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=&year=
POST /api/v1/payroll/payslips/{id}/regenerate
GET  /api/v1/payroll/payslips/{id}/download
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Generate payslips** | run status = `draft`, `payroll.run-create` | `POST /payroll-runs/{id}/generate-payslips` | Progress view; survives navigation | 422 no active tax slab with taxable employees present |
| 2 | Leave and return mid-generation | batch running | `GET /generation-status` | Progress view restored | — |
| 3 | **Retry failed employees** | generation finished with failures | `POST /generate-payslips` scoped to those employees | Only the failed employees regenerate | — |
| 4 | Filter **needs review** | always | `GET /payroll-runs/{id}/payslips` | The rows Finance must look at before approving | — |
| 5 | **Regenerate** one payslip | status = `draft` | `POST /payslips/{id}/regenerate` | Recomputed; note states loan installments re-apply idempotently | 409 on a `finalized` or `paid` payslip |
| 6 | Open payslip detail | `payslip-view-all`, or own with `-own` | `GET /payslips/{id}` | Breakdown, then the **settlement block** (net pay → less advance → net payable) and the channel split | 403 reading someone else's with only `-own` |
| 7 | **Download** | as #6 | `GET /payslips/{id}/download` | PDF carrying the same three settlement lines | — |
| 8 | Advance line → **Advance record** | `advance_paid > 0` | — | Navigates to 7.5-FE | — |

### UI Rules
- Generation shows live progress and survives navigation; returning to the run restores the progress view.
- Failed employees are listed with their reason and a retry action scoped to those employees only.
- Rows flagged `needs_review` are visually prominent and filterable — they are the ones Finance must look at before approving.
- 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.
- Regenerate appears only on draft payslips, with a note that it re-applies loan installments idempotently.
- Employee self-service shows only their own payslips, with no run-level controls.
- Amounts render with the payslip's own `currency_code`, not a global default.
- **Net pay and net payable are never shown as one figure.** 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.
- 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.
- A payslip with `advance_carry_forward > 0` shows an explicit "carried to next month" line and the `needs_review` flag, rather than a bare zero payable.
- The split panel shows each channel's amount and, for bank lines, the masked account; a capped fixed line is marked as capped rather than silently reduced.
- The split panel is hidden entirely when `net_payable` is zero, replaced by a short explanation.

### Acceptance Criteria
- Finance can generate a full run and see per-employee failures without losing progress on navigation.
- A flagged payslip is easy to find and explains why it is flagged.
- An employee can view and download their own payslip and cannot reach anyone else's.
- The detail view links to the attendance data behind the figures.
- Net pay, advance paid, and net payable are always three visible lines, including when the advance is zero.
- The channel amounts shown sum to the net payable on screen, matching the API to the paisa.
- An employee with a carried-forward advance sees it stated, not just a zero payable.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/payslipApi.ts` with generation-status polling.
- My-payslips entry under Payroll for all employees.
- The split display reuses the shared allocation helper from 7.6-FE rather than reformatting the rules a second time.

---

## TASK 8.3a-BE: Payroll Run Approval & Advance Settlement — Backend

**Task Type:** Backend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.2b-BE Advance Settlement & Allocation · 4.1-BE Approval Integration
**Also needs (can be stubbed):** 7.5-BE Salary Advances
**Permission:** `payroll.run-approve`
**Menu:** none — API only; surfaces in 8.3a-FE
**Points:** 5

> Split from the original 13-point 8.3-BE. This card closes the run and settles advances; **8.3b-BE** 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.

### Task Summary
*As Finance, I want to approve a payroll run so its payslips are finalised and every advance already paid is closed out against them.*

### Related Tables
- `payroll_runs`, `payslips`, `salary_advances`, `employee_deductions`, `approval_requests` — all existing. **No new tables, no new columns.**

### API Endpoints
```
GET /api/v1/payroll/payroll-runs/{id}/approval-summary
```

Approval uses the platform's existing approve/reject endpoints, as elsewhere. **No approve route is added here.**
```
POST /api/v1/approval-requests/{id}/approve
POST /api/v1/approval-requests/{id}/reject
```

`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.

### Business Rules
- A run is routed through the approval engine with `actionSlug: 'run-approve'` and `correlationId: "payroll_run:{id}"`.
- `PayrollRunExecutor` (stub from 4.1) is implemented here: it sets the run to `approved` and every payslip in it to `finalized`, locking both against edits.
- **The executor also closes out salary advances**, which is why draft generation deliberately leaves them alone (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`;
  - any `advance_carry_forward > 0` 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.
- **Settlement is idempotent.** 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's `remarks` reference.
- Approval is blocked if any payslip in the run is still `draft` and generation is incomplete — a partially generated run must not be approved.
- Rejection requires a reason, leaves every payslip `draft`, and settles nothing.
- Emits `PayrollRunApproved` and `PayslipPublished`.

### Calculation Pseudocode
```
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 > 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)
```

### Validation Rules
- Every payslip in the run must be `draft` and generation must be reported complete.
- Rejection requires `decision_reason`, max 500.

### Error Handling
- Approving a run whose generation is incomplete → **409** naming the outstanding employee count.
- Run already finalised → **409**.
- Rejection without a reason → **422**.

### Acceptance Criteria
- A run cannot reach `approved` without going through the approval engine.
- Approving a run locks it and every payslip in it against further edits.
- Approving a run moves every netted advance to `settled` with its payslip stamped, exactly once even if approval is replayed.
- An over-advanced employee gets an `employee_deductions` advance row for the excess, starting the following month, and replaying approval raises no second row.
- Rejecting a run leaves every payslip `draft` and every advance still `paid`.
- `approval-summary` states the advance settlement impact before the decision is made.
- A run with an incomplete generation cannot be approved.

### Definition of Done
Platform DoD plus:
- `PayrollRunExecutor` implemented, registered, and tested through both the bypass and workflow paths.
- Advance settlement replay test: approve, replay the executor, assert one `settled` transition and one carry-forward deduction row.
- Rejection path tested for zero side effects.
- `api collection/Payroll/Payroll Runs/approval-*.yml`.

---

## TASK 8.3b-BE: Multi-Channel Disbursement — Backend

**Task Type:** Backend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.3a-BE Run Approval · 8.2b-BE Advance Settlement & Allocation
**Permission:** `payroll.disburse`
**Menu:** none — API only; surfaces in 8.3b-FE
**Points:** 8

> Split from the original 13-point 8.3-BE. **8.2b-BE is a hard prerequisite** — this card reads `payslip_payment_allocations`, which does not exist without it. Building on top of 8.2a-BE alone would pay every employee their full `net_pay`, ignoring advances.

### Task Summary
*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.*

### Related Tables
- `disbursement_batches` (new)
- `disbursement_batch_items` (new)
- `payslip_payment_allocations` — the frozen split this reads, see 8.2b-BE
- `payroll_runs`, `payslips` (existing)

### Related DB Schema
**disbursement_batches**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `company_id` | unsignedBigInteger | Multi-tenant scope |
| `payroll_run_id` | unsignedBigInteger | Which run this batch pays |
| `batch_reference` | varchar(100) | Bank or gateway reference |
| `payout_channel` | enum(`bank`,`mobile_banking`,`cash`,`cheque`) | One batch per channel |
| `total_amount` | decimal(15,2) | Sum disbursed in this batch |
| `status` | enum(`pending`,`sent`,`confirmed`,`failed`) | Batch state |
| `sent_at` | datetime nullable | When sent |
| `failure_reason` | text nullable | Why the batch failed |
| `created_at` / `updated_at` | timestamp | Laravel timestamps |

**disbursement_batch_items**

| Field | Type | Description |
|---|---|---|
| `id` | bigint PK | Primary key |
| `disbursement_batch_id` | unsignedBigInteger | Parent batch |
| `payslip_id` | unsignedBigInteger | Payslip being paid |
| `payslip_payment_allocation_id` | unsignedBigInteger | The frozen allocation this item pays |
| `employee_id` | unsignedBigInteger | `employee_personal_infos.id` |
| `employee_bank_account_id` | unsignedBigInteger **nullable** | Destination account; null for cash and cheque |
| `amount` | decimal(12,2) | This channel's slice for this employee |
| `payment_reference` | varchar(100) nullable | Cheque no. / txn id / voucher no. |
| `acknowledged_by` / `acknowledged_at` | unsignedBigInteger / datetime nullable | Cash receipt acknowledgement |
| `status` | enum(`pending`,`sent`,`confirmed`,`failed`) | Item state |
| `failure_reason` | text nullable | Per-item failure |

Keys: `unique(payslip_payment_allocation_id)` · `index(disbursement_batch_id, status)`

Items exist so a retry re-sends only the failed rows. A batch-level retry without them would re-pay everyone.

The unique key is on the **allocation**, not on `(batch, payslip)`: **one payslip can legitimately appear in three batches**, 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.

### API Endpoints
```
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=&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
```

`channel-summary` totals the run's allocations by channel before any batch exists, so Finance knows how much cash to draw.

### Business Rules
- Disbursement requires the run to be `approved` (8.3a-BE).
- **Batches are built from `payslip_payment_allocations`, not from payslips.** 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.
- **The readiness rule is channel-aware.** An employee is blocked only when they have a `bank` or `mobile_banking` allocation above zero and no valid account for it. **An employee paid entirely in cash needs no bank account and must not be excluded** — the original "no primary payment method" rule would have wrongly blocked them. `disbursement-readiness` reports only genuine gaps, each linked to the employee's bank-account screen.
- Payslips with `net_payable = 0` have no allocations and therefore no items; they are reported as fully settled by advance, not as failures.
- A batch's `total_amount` must equal the sum of its items; asserted on creation.
- `retry` re-sends only items with `status = failed`, and never re-sends a `confirmed` item.
- **`cash` and `cheque` batches are registers, not transmissions.** Nothing is sent anywhere. `send` and `retry` do not apply to them; their items move `pending → confirmed` through `acknowledge` (stamping `acknowledged_by`/`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.
- A payslip becomes `paid` only when **every** item across **all** 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`.
- Re-running `disburse` on a run that already has batches creates no duplicate items — the unique key on the allocation is the guard.
- `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.
- Emits `PayslipPaid` and, when the last item confirms, `PayrollRunPaid`.

### Calculation Pseudocode
```
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: "> 0")

    # channel-aware readiness: only bank-like channels need an account
    missing = allocations.filter(a => 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 => 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
```

### Validation Rules
- Disburse — run must be `approved` and have at least one allocation above zero.
- Confirm — batch must be `sent`, and must not be a `cash`/`cheque` register.
- Retry — batch must be `failed` or contain failed items, and must not be a register.
- Acknowledge — batch must be `cash` or `cheque`; the item must not already be `confirmed`; a cheque item requires a reference.

### Error Handling
- Disbursing a run that is not approved → **409**.
- Confirming a batch that was never sent → **409**.
- Retrying a batch with no failed items → **422**.
- `send`, `confirm`, or `retry` on a `cash`/`cheque` register → **409** stating that registers are acknowledged instead.
- `acknowledge` on a bank or mobile-banking batch → **409**.
- Acknowledging a cheque item without a reference → **422**.
- Batch total not matching item sum → **500** with the transaction rolled back; this is an invariant violation, not a user error.

### Acceptance Criteria
- **An employee paid entirely in cash is not reported as missing a payment method and is not excluded** — this is the specific regression the old rule would have caused.
- An employee with a bank allocation and no valid account is reported and excluded, never silently omitted.
- A run with cash, cheque, and bank allocations produces exactly three batches whose totals sum to the run's `net_payable` total.
- One payslip split across three channels produces exactly three items, one per allocation, and re-running disburse creates no duplicates.
- Batch total equals the sum of its items, asserted on creation.
- Retrying a partially failed bank batch re-sends only the failed items; confirmed items are untouched and no employee is paid twice.
- `send` on a cash register returns 409; acknowledging its items individually confirms them.
- A payslip whose bank leg is confirmed but whose cash leg is not stays unpaid; confirming the cash leg closes it.
- A payslip with `net_payable = 0` produces no items and is reported as settled by advance rather than as a failure.
- `channel-summary` totals match the batches subsequently created, to the paisa.
- A run becomes `paid` only when every item of every batch is confirmed.

### Definition of Done
Platform DoD plus:
- Migrations for both tables.
- Retry idempotency test: fail three of ten items, retry, assert exactly three sends and ten total payments.
- Split-payslip test: one employee across cash + cheque + bank, asserting three items and payslip closure only after all three confirm.
- Cash-only-employee readiness test, asserting they are neither reported nor excluded.
- Double-disburse test asserting the allocation unique key prevents duplicate items.
- Bank-format export implemented for at least one bank channel, plus a printable cash register and cheque schedule.
- `api collection/Payroll/Disbursement/*.yml`.

---

## TASK 8.3a-FE: Payroll Run Approval & Readiness — Frontend

**Task Type:** Frontend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.3a-BE Run Approval
**Also needs (can be stubbed):** 4.1-FE Approval Settings
**Permission:** `payroll.run-approve`
**Menu:** **Payroll › Payroll Runs** — the approval screen on the run detail
**Points:** 5

> Split from the original 8-point 8.3-FE. This card is the decision screen; **8.3b-FE** is the operational screen for moving money. They share no components beyond the shared badges.

### Task Summary
*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.*

### Related Tables
- `payroll_runs`, `payslips`, `salary_advances` — see 8.3a-BE and 8.2b-BE.

### Frontend Routes
```
/payroll/payroll-runs/{id}/approval
```

### Main Screen Sections
- **Run approval screen** — 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.
- **Flagged payslip list** — the `needs_review` rows inline, each linking to its payslip, so approving past a flag is a deliberate act rather than an overlooked number.
- **Advance settlement preview** — how many advances will move to settled, their total, and how many carry-forward recoveries will be raised.
- **Approval trail** — the shared `<ApprovalStatusBadge />` and step history from 4.1-FE.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Open the approval screen | `payroll.run-approve` | `GET /payroll-runs/{id}/approval-summary` | Net pay and net payable as **two separate totals**, `needs_review` count, and the advance settlement impact | — |
| 2 | Click the review count | count > 0 | `GET /payslips?needs_review=true` | Flagged rows inline, each linking to its payslip | — |
| 3 | **Approve** | generation complete | `POST /approval-requests/{id}/approve` | Run `approved`, payslips `finalized`, advances `settled`, carry-forwards raised | 409 incomplete generation → outstanding count named |
| 4 | **Approve** — the dialog | — | — | States it will settle N advances totalling X and raise M carry-forward recoveries, because this is where it becomes irreversible | — |
| 5 | **Reject** | always | `POST /approval-requests/{id}/reject` | Payslips stay `draft`, advances stay `paid` | 422 empty reason |
| 6 | After approval | — | — | Screen becomes read-only and links forward to disbursement (8.3b-FE) | — |

### UI Rules
- The `needs_review` count is prominent and linked — approving past a flagged payslip should be a conscious act.
- **Net pay and net payable are shown as two separate totals**, never merged, so nobody approves believing the company is about to move the larger figure.
- 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.
- Reject requires a typed reason with no default text and cannot be submitted empty.
- Approve is disabled with a stated reason while generation is incomplete.
- Once the run is approved, the screen becomes read-only and links forward to the disbursement screen (8.3b-FE).

### Acceptance Criteria
- Finance can see the flagged-payslip count and the advance settlement impact before approving.
- Net pay and net payable are visibly distinct totals.
- Rejecting without a reason is impossible.
- An incompletely generated run cannot be approved, and the screen says why.
- An approved run offers no further approval actions and points to disbursement.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/payrollRunApi.ts` extended with the approval-summary call.
- Reuses `<ApprovalStatusBadge />` from 4.1-FE.

---

## TASK 8.3b-FE: Disbursement Batches & Registers — Frontend

**Task Type:** Frontend · **Part:** H — Payroll execution · **Module:** Payroll
**Start after:** 8.3b-BE Disbursement
**Also needs (can be stubbed):** 8.3a-FE Run Approval screen
**Permission:** `payroll.disburse`
**Menu:** **Payroll › Disbursement**
**Points:** 5

> Split from the original 8-point 8.3-FE. This card owns everything after approval — readiness, batch creation, and the two very different batch detail screens.

### Task Summary
*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.*

### Related Tables
- `disbursement_batches`, `disbursement_batch_items`, `payslip_payment_allocations` — see 8.3b-BE and 8.2b-BE.

### Frontend Routes
```
/payroll/disbursement-batches
/payroll/disbursement-batches/{id}
```

### Main Screen Sections
- **Channel summary panel** — before disbursing, the run's money broken down by channel with a per-channel total, so Finance knows how much cash to draw before anything is created.
- **Disbursement readiness panel** — 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.
- **Batch list** — channel, reference, total, status, item counts by state, with register batches visually distinguished from transmitted ones.
- **Batch detail (bank / mobile banking)** — item table with per-employee status and failure reason; Send, Confirm, Retry, and Export actions.
- **Batch detail (cash / cheque register)** — 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.

### API Integration
```
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
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | Review **channel summary** | run `approved` | `GET /payroll-runs/{id}/channel-summary` | Per-channel totals **before** any batch exists, so Finance knows how much cash to draw | — |
| 2 | Review **readiness** | run `approved` | `GET /disbursement-readiness` | Only genuine gaps; panel states cash-paid employees are not listed, so empty ≠ broken | — |
| 3 | **Disburse** | `payroll.disburse` | `POST /payroll-runs/{id}/disburse` | One batch per channel present in the run | 409 run not approved |
| 4 | **Send** | bank / mobile batch, status `pending` | `POST /batches/{id}/send` | Batch `sent`; dialog names total and item count | 409 on a cash or cheque register |
| 5 | **Confirm** | bank / mobile batch, status `sent` | `POST /batches/{id}/confirm` | Items confirmed | 409 if never sent |
| 6 | **Retry** | bank / mobile batch with failed items | `POST /batches/{id}/retry` | Only failed items re-sent; dialog states the exact count and that confirmed items are untouched | 422 no failed items |
| 7 | **Acknowledge** one row | cash or cheque register | `POST /batch-items/{id}/acknowledge` | Item `confirmed`, acknowledger and time stamped | 422 cheque row without a cheque number |
| 8 | **Acknowledge all** | cash or cheque register | `POST /batches/{id}/acknowledge-all` | All items confirmed; dialog names count and total and calls it a bookkeeping action | — |
| 9 | **Print register** | cash or cheque batch | `GET /batches/{id}/export` | Signable sheet — name, id, amount, signature column (cheque schedule adds the number column) | — |
| 10 | **Export** | bank / mobile batch | `GET /batches/{id}/export` | Bank-format file | — |

**Deliberately absent on register batches:** Send and Retry are **not rendered**. A disabled Send on a cash batch would imply money moves through the system; it does not.

### UI Rules
- 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.
- 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.
- Item statuses are colour-coded and filterable; the failure reason is shown inline, not behind a hover.
- Send and Confirm require confirmation dialogs naming the batch total and item count.
- **Register batches never show Send or Retry.** Offering a disabled Send on a cash batch would imply money moves through the system; it does not.
- 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.
- Bulk Acknowledge all names the count and total and states that it is a bookkeeping action recording handovers that already happened.
- A partly confirmed payslip is shown as partly confirmed across its channels, not as unpaid, so Finance can see which leg is outstanding.
- Once a run is paid, all mutating actions disappear from the screen rather than being disabled.

### Acceptance Criteria
- The per-channel totals are visible before any batch is created.
- Excluded employees are visible and fixable before batches are created, and cash-only employees are never among them.
- Retrying a partially failed batch clearly states its scope before running.
- A cash batch offers Acknowledge and Print, and offers neither Send nor Retry.
- A cheque row cannot be acknowledged without a cheque number.
- A payslip with one confirmed and one outstanding leg is shown as partly paid.
- A paid run offers no mutating actions.

### Definition of Done
Platform DoD plus:
- `modules/payroll/api/disbursementApi.ts`.
- Print stylesheet for the cash register and cheque schedule, verified against an A4 print preview.
- Nav entry under Payroll.

---

# Part J — Automation

These three stories were absent from the source document. Without them the module does not function: no Absent day is ever created, no leave ever accrues, and no attendance type exists to calculate against.

## TASK 9.1-BE: Nightly Attendance Close — Backend

**Task Type:** Backend · **Part:** J — Automation · **Module:** Attendance
**Start after:** 3.2-BE Daily Summary
**Permission:** n/a (scheduled job) · manual trigger requires `attendance.record-recalculate`
**Menu:** none — API only; surfaces in 9.4-FE
**Points:** 5

### Task Summary
*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.*

### Related Tables
- `attendance_records`, `attendance_punches` (existing)

### API Endpoints
```
POST /api/v1/attendance/jobs/close-day   body: { "date": "2026-07-26", "employee_id": null }
GET  /api/v1/attendance/jobs/close-day/{batchId}
```

Manual trigger for backfilling; the normal path is the scheduler.

### Business Rules
- Runs per company at **02:00 company-local time**, using `companies.timezone` (Story 0.2). A single global 02:00 UTC schedule would close the wrong day for some tenants.
- For the previous day, for every active employee with no `attendance_records` row, runs `calculateDaily` (Story 3.2). This is what actually creates Absent, Holiday, and Weekend rows.
- Dates that resolve as unassigned are **skipped and reported**, never defaulted to a shift.
- Locked records are skipped.
- Idempotent: re-running produces no changes for days already closed.
- Emits a summary to the activity log: employees processed, rows created, rows skipped, unassigned count.
- Chunked over employees so a large tenant does not exhaust memory.

### Calculation Pseudocode
```
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)                        # Story 3.2
        stats.processed++

    activityLog('attendance.day_closed', company_id, date, stats)
    return stats
```

### Validation Rules
- `date` — not in the future; at most 90 days in the past for a manual run.
- `employee_id` — optional; when given, closes that employee only.

### Error Handling
- A failure on one employee is logged and the job continues; it does not abort the batch.
- Manual run for a future date → **422**.
- Manual run beyond 90 days → **422**.

### Acceptance Criteria
- An employee who never punched on a working day has an Absent record by the next morning.
- A weekend produces a Weekend record, not an Absent one.
- An employee with no shift assigned is reported as unassigned and gets no record at all.
- Re-running the job for the same date produces no changes and no duplicate rows.
- A locked day is skipped even if it has no record.
- Two companies in different timezones each close their own previous day correctly.

### Definition of Done
Platform DoD plus:
- Scheduler registration per company timezone, with a test covering two tenants in different zones.
- Idempotency test running the job twice and asserting identical state.
- Unassigned employees surfaced in a report HR can read, not only in logs.

---

## TASK 9.2-BE: Leave Accrual & Carry-Forward — Backend

**Task Type:** Backend · **Part:** J — Automation · **Module:** Attendance
**Start after:** 5.1-BE Leave Balances
**Also needs (can be stubbed):** 1.3-BE Policies
**Permission:** manual trigger requires `attendance.leave-balance-adjust`
**Menu:** none — API only; surfaces in 9.4-FE
**Points:** 5

### Task Summary
*As HR, I want leave to accrue and carry forward automatically according to each policy, because the balance table cannot maintain itself.*

### Related Tables
- `leave_balances`, `leave_balance_ledger`, `policies` (existing)

### API Endpoints
```
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}
```

Manual triggers exist for backfilling; the normal path is the scheduler.

### Business Rules
- **Monthly accrual**, on the 1st at 01:00 company-local, for policies with `accrual_method = monthly`: adds `entitlement_days / 12`, writes an `accrual` ledger row.
- **Annual accrual**, for `accrual_method = annual`: the full entitlement is granted when the year's balance row is created.
- **On joining**, for `accrual_method = on_joining`: pro-rated from the joining date for the first year.
- Accrual is **idempotent per `(employee, policy, year, month)`** — the ledger is checked before writing, so a re-run adds nothing.
- **Year-end carry-forward**, on 1 January at 02:00 company-local:
  `carry = min(available, policy.config.max_carry_forward)` into the next year's balance, written as a `carry_forward` ledger row on the new row.
- Carry-forward runs only for policies with `carry_forward_allowed = true`; others start the new year at zero carried forward.
- Balance rows for the new year are created by this job for every employee holding an active leave-policy assignment.
- Every mutation writes a ledger row in the same transaction (Story 5.1).

### Calculation Pseudocode
```
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   # idempotent

        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                      # idempotent

        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 })
```

### Validation Rules
- `month` 1–12; `year` and `from_year` within ±5 of the current year.
- Manual triggers require `attendance.leave-balance-adjust`.

### Error Handling
- A failure on one employee is logged and the job continues.
- Re-running an already-applied accrual returns a count of zero applied rather than an error.

### Acceptance Criteria
- A monthly-accrual policy adds one twelfth of the entitlement each month, and running the job twice in one month adds it once.
- Year-end carry-forward respects the policy's cap.
- A policy with carry-forward disabled starts the new year with zero carried forward.
- New-year balance rows exist for every employee with an active leave assignment after the job runs.
- The ledger reconciles to the balance columns after both jobs, asserted by the Story 5.1 test.
- A manual backfill for a past month produces the same result as the scheduled run would have.

### Definition of Done
Platform DoD plus:
- Scheduler registration for both jobs, per company timezone.
- Idempotency tests for both, running each twice.
- All three accrual methods unit-tested, including a mid-year joiner.

---

## TASK 9.3-BE: Default Data Seeders — Backend

**Task Type:** Backend · **Part:** J — Automation · **Module:** Attendance
**Start after:** 1.1-BE Attendance Types · 1.2-BE Shifts
**Permission:** n/a (seeder)
**Menu:** **none anywhere** — seeder, runs on deploy and on company provisioning. The only card in the plan with no user-facing surface, and correctly so
**Points:** 3

### Task Summary
*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.*

### Related Tables
- `attendance_types`, `shifts` (existing)

### Seeders
- `AttendanceTypeSeeder` — the eleven system types, per company, with `is_system = true` and their fixed `system_code`:

| `system_code` | Name | `is_paid` | `counts_as_working_day` | `eligible_for_payroll` |
|---|---|---|---|---|
| `present` | Present | true | true | true |
| `late` | Late | true | true | true |
| `half_day` | Half Day | true | true | true |
| `absent` | Absent | false | false | true |
| `leave` | Leave | true | false | true |
| `holiday` | Holiday | true | false | false |
| `weekend` | Weekend | true | false | false |
| `wfh` | Work From Home | true | true | true |
| `business_trip` | Business Trip | true | true | true |
| `missing_check_in` | Missing Check-In | false | true | true |
| `missing_check_out` | Missing Check-Out | false | true | true |

- `ShiftSeeder` — one default `GENERAL` shift, 09:00–18:00, 60-minute break, 8 working hours, 15-minute grace, `min_hours_present` 6, `min_hours_half_day` 3, working days Sun–Thu.

### Business Rules
- Both seeders are idempotent, keyed on `(company_id, system_code)` and `(company_id, code)` respectively, using `updateOrCreate`.
- 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.
- Seeded rows carry `is_system = true`, so Story 1.1's delete guard protects them.
- Colours and icons are set to sensible defaults; HR may change them, and the engine is unaffected because it reads `system_code`.
- The seeder never overwrites an HR-customised `name`, `color`, or `icon` on re-run — only missing rows are created.

### Acceptance Criteria
- A fresh install has eleven system attendance types and one shift per company.
- Re-running the seeders creates no duplicates and does not revert HR customisations.
- Creating a new company provisions both sets automatically.
- Deleting a seeded type is refused by the API (Story 1.1).
- `calculateDaily` resolves every status it needs immediately after seeding, with no manual configuration.

### Definition of Done
Platform DoD plus:
- Both seeders registered in `DatabaseSeeder` and wired into company provisioning.
- Re-run test asserting no duplicates and preserved customisations.

---

## TASK 9.4-FE: Attendance Jobs — Frontend

**Task Type:** Frontend · **Part:** J — Automation · **Module:** Attendance
**Start after:** 9.1-BE Nightly Close · 9.2-BE Leave Accrual
**Permission:** `attendance.record-recalculate` · `attendance.leave-balance-adjust`
**Menu:** **Attendance › Jobs**
**Points:** 3

> **Why this card exists.** 9.1-BE and 9.2-BE ship manual-trigger endpoints behind HR permissions, and 9.1-BE promises "unassigned employees surfaced in a report HR can read, not only in logs" — but no screen was ever specified for either. Without this card HR must ask a developer to run a backfill, and a silently skipped employee is invisible until the month cannot be closed.

### Task Summary
*As HR, I want to see whether last night's jobs ran, re-run them for a date range, and find the employees they skipped, without asking a developer.*

### Related Tables
- `attendance_records`, `leave_balances`, `leave_balance_ledger`, `activity_log` — all existing. **No new tables, no new endpoints.**

### Frontend Routes
```
/attendance/jobs
```

### Main Screen Sections
- **Job status panel** — 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).
- **Unassigned employees report** — 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 card.
- **Manual run panel** — date or period picker plus a Run button per job, with the permitted range enforced (nightly close: at most 90 days back; accrual: ±5 years).
- **Batch progress** — for a queued run, live progress with per-employee failures, surviving navigation.

### API Integration
```
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}
```

### Actions

| # | Action | Visible / enabled when | Calls | Result | Main failure |
|---|---|---|---|---|---|
| 1 | **Run nightly close** | `attendance.record-recalculate`; date ≤ today and ≥ today−90 | `POST /jobs/close-day` | Switches to progress view; status panel refreshes on completion | 422 future date or >90 days → date picker rejects before submit |
| 2 | **Run for one employee** | as #1, employee selected | `POST /jobs/close-day` with `employee_id` | Single-employee result, no batch | 422 same as #1 |
| 3 | **Accrue leave** | `attendance.leave-balance-adjust` | `POST /jobs/accrue-leave` | Applied count shown; re-running the same month reports **0 applied**, not an error | — |
| 4 | **Carry forward** | `attendance.leave-balance-adjust` | `POST /jobs/carry-forward` | Rows created for the next year | — |
| 5 | Unassigned row → **Fix** | always | — | Navigates to Assignments (1.4-FE) pre-filtered to that employee | — |
| 6 | **Refresh status** | always | `GET /jobs/{batchId}` | Polls while a batch is running | — |

### UI Rules
- Times render in **company-local** time with the zone shown — these jobs are scheduled per company timezone, and a UTC timestamp would be actively misleading.
- Re-running an already-applied accrual reports "0 applied" as a **success**, not an error. The endpoints are idempotent; the UI must not make a safe action look dangerous.
- The unassigned report is the screen's primary content, not a footnote — it is the only place these employees are visible at all.
- A job that has never run shows "Never run" with the scheduler's expected time, so a misconfigured scheduler is visible rather than silent.
- Manual-run controls are hidden, not disabled, for users lacking the permission.

### Acceptance Criteria
- HR can see when each job last ran and what it did, without reading logs.
- An employee skipped for having no shift is listed and reachable in one click.
- HR can backfill a past date without developer help, within the permitted range.
- Re-running an accrual for an already-accrued month reports zero applied and changes nothing.
- Leaving the page during a queued run and returning restores the progress view.
- A user without the job permissions sees the status panel but no run controls.

### Definition of Done
Platform DoD plus:
- `modules/attendance/api/attendanceJobApi.ts` with batch-status polling, reusing the shared polling hook.
- Nav entry under Attendance.

---

# Part K — Employee Module Adjustments

Three small tasks on the **existing Employee module**. They are not new stories; the source document's Stories 7.3, 7.4, and 7.7 proposed rebuilding endpoints that already ship, and these are the only genuine gaps.

Existing endpoints consumed as-is:

| Need | Existing endpoint |
|---|---|
| Salary assignment & history | `POST /api/v1/employee/salaries` · `GET /api/v1/employee/employees/{id}/salaries` |
| Tax profile | `POST` / `PUT /api/v1/employee/{employeeId}/tax-profiles` |
| Bank / mobile banking | `POST` / `PUT /api/v1/employee/bank-accounts` · `POST /api/v1/employee/bank-accounts/{id}/set-primary` |

## TASK 10.1-BE: Salary Field Validation — Backend

**Task Type:** Backend · **Module:** Employee
**Start after:** nothing — this card can start immediately
**Permission:** existing `employee.create` / `employee.update`
**Menu:** **Employees › Salary tab** — validation added to the existing form, no new screen
**Points:** 1

### Task Summary
*As HR, I want currency and payment frequency validated against the configured allow-lists, so payroll never encounters a value it cannot process.*

### Business Rules
- `employee_salaries.currency_code` must be in `config('employee.salaries.currency_codes')`.
- `employee_salaries.payment_frequency` must be in `config('employee.salaries.payment_frequencies')`.
- `salary_structure_id` must reference a structure with `status = Active`.
- `gross_salary` must be greater than zero.

Both config lists already exist in `Modules/Employee/config/config.php`; they are simply not enforced.

### Acceptance Criteria
- Assigning an unsupported currency is rejected with 422.
- Assigning an inactive salary structure is rejected with 422.
- Existing rows with legacy values continue to read without error.

---

## TASK 10.2-BE: Bank Account Payment-Method Validation — Backend

**Task Type:** Backend · **Module:** Employee
**Start after:** nothing — this card can start immediately
**Permission:** existing `employee.create` / `employee.update`
**Menu:** **Employees › Bank Accounts** — validation added to the existing form, no new screen
**Points:** 1

### Task Summary
*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.*

### Business Rules
- At least one of (`bank_name` + `account_number`) or (`mobile_banking_provider` + `mobile_banking_number`) must be populated.
- Only one `is_primary = 1` row per employee. Setting a new primary automatically unsets the previous one — this is not an error.

### Acceptance Criteria
- A row with neither method populated is rejected with 422.
- Setting a second primary silently unsets the first and returns success.
- An employee always has at most one primary account, asserted by a test.

---

## TASK 10.3-BE: Missing Payment Method Report — Backend

**Task Type:** Backend · **Module:** Employee
**Start after:** 10.2-BE Bank Account Validation
**Permission:** `payroll.disburse` or `employee.menu-view`
**Menu:** none — API only; consumed by the readiness panel in 8.3b-FE
**Points:** 3

### Task Summary
*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.*

### API Endpoints
```
GET /api/v1/employee/employees/without-primary-payment-method?department_id=
```

### Business Rules
- Returns active employees with no `employee_bank_accounts` row where `is_primary = 1`.
- Filterable by department, branch, and employment status.
- Consumed by Story 8.3b's `disbursement-readiness` endpoint rather than duplicating the query there.

### Acceptance Criteria
- The report lists exactly the employees a disbursement would exclude.
- Story 8.3b's readiness check calls this endpoint rather than reimplementing it.
- Filtering by department narrows the result correctly.

---

# Appendix — Traceability

## Source card mapping

| Source card | This document |
|---|---|
| 1.1 – 1.4 | Same ids; endpoints corrected with the module prefix, `system_code` added, `scope_type` enum replaces `org_units` |
| 2.1, 2.2 | Same ids; org-unit membership resolved as of the requested date |
| 3.1, 3.2 | Same ids; punch supersession added, status-ladder ordering corrected |
| 4.1 | Rewritten against the real approval engine |
| 5.1 – 5.5 | Same ids; ledger table added, approve/reject routes removed in favour of platform endpoints |
| 6.1, 6.2 | Same ids; 6.2 moved to the Payroll module, `unresolved_flag` and `total_unpaid_leave_days` added |
| 6.3 | **Renumbered 8.0** and moved after 8.1 |
| 7.1, 7.2 | **Renumbered 7.1, 7.2**; `prorated` and `component_code` added |
| 7.3, 7.4, 7.7 | **Dropped** — already shipped in the Employee module; see Part K |
| 7.5 | **Renumbered 7.3** (tax slabs); bracket-continuity validation added |
| 7.6 | **Renumbered 7.4** (deductions); entries table added for idempotency |
| 8.1 – 8.3 | Same ids; batch items added, generation queued and chunked, advance settlement and multi-channel disbursement added |
| — | **New:** 0.1, 0.2, 0.3, 7.0, 7.5, 7.6, 9.1, 9.2, 9.3, 9.4, 10.1, 10.2, 10.3 |

27 source stories → 24 carried forward + 10 new = **34 stories**, plus 3 small Employee-module tasks.

9.4 (Attendance Jobs) was added on 28 July 2026 after a menu audit found that 9.1-BE and 9.2-BE ship HR-facing manual-trigger endpoints, and an unassigned-employee report, with no screen anywhere.

7.5 (salary advances) and 7.6 (payment-mode split) were added on 28 July 2026 from decisions R3 and R4 below. They have no counterpart in the source document.

## Menu traceability

Every nav item in the shipped product, and the cards that build it. Read top-to-bottom this is the sidebar; read a row and you get the work behind it.

### Attendance

| Nav item | Cards | Points |
|---|---|---|
| **Configuration › Attendance Types** | 1.1-BE · 1.1-FE | 12 |
| **Configuration › Shifts** | 1.2-BE · 1.2-FE | 8 |
| **Configuration › Policies** | 1.3-BE · 1.3-FE | 10 |
| **Configuration › Assignments** | 1.4a-BE · 1.4b-BE · 1.4-FE · 2.1-BE · 2.1-FE *(preview, linked not nav)* | 23 |
| **Punch** | 3.1-BE · 3.1-FE | 8 |
| **Attendance Records** | 3.2-BE · 3.2-FE · 2.2-BE · 2.2-FE *(applied-policy panel)* · 5.3b-BE *(voided-day marker)* | 26 |
| **Corrections › Requests** | 5.4-BE · 5.4-FE | 6 |
| **Corrections › Approvals** | 5.5-BE · 5.5-FE | 8 |
| **Leave › Requests** | 5.2-BE · 5.2-FE | 10 |
| **Leave › Approvals** | 5.3a-BE · 5.3-FE | 8 |
| **Leave › Balances** | 5.1-BE · 5.1-FE | 8 |
| **Monthly Approval** | 6.1-BE · 6.1-FE | 13 |
| **Jobs** | 9.1-BE · 9.2-BE · 9.4-FE | 13 |

### Payroll

| Nav item | Cards | Points |
|---|---|---|
| **Configuration › Payroll Settings** | 7.0-BE · 7.0-FE | 6 |
| **Configuration › Salary Structures** | 7.1-BE · 7.1-FE · 7.2-BE · 7.2-FE *(embedded component builder)* | 15 |
| **Configuration › Tax Slabs** | 7.3-BE · 7.3-FE | 6 |
| **Deductions & Loans** | 7.4-BE · 7.4-FE | 8 |
| **Salary Advances** *(hidden unless `advance_enabled`)* | 7.5-BE · 7.5-FE | 13 |
| **Monthly Freeze** | 6.2-BE · 6.2-FE | 5 |
| **Payroll Runs** | 8.1-BE · 8.1-FE · 8.0-BE · 8.0-FE *(snapshots)* · 8.2a-BE · 8.2b-BE · 8.2-FE *(payslips)* · 8.3a-BE · 8.3a-FE *(approval)* | 60 |
| **Disbursement** | 8.3b-BE · 8.3b-FE · 10.3-BE *(readiness feed)* | 16 |
| **My Payslips** | 8.2-FE *(second surface of the same card)* | — |

### Outside the two new menus

| Where | Cards | Note |
|---|---|---|
| **Admin › Companies** | 0.2-BE | One field on the existing form |
| **Admin › Roles** | 0.3-BE | Existing Actions / Modules / Roles UI |
| **Admin › Approval Settings** | 4.1-BE · 4.1-FE | Existing platform screen, extended |
| **Employees › Salary tab** | 7.6-BE · 7.6-FE · 10.1-BE | Payroll-owned split panel plus salary validation |
| **Employees › Bank Accounts** | 10.2-BE | Validation on the existing form |
| **Both nav groups + landing pages** | 0.1-BE · 0.1-FE | Creates the Attendance and Payroll groups |
| **Nowhere** | 9.3-BE | Seeders — run on deploy and company provisioning. The only card with no user-facing surface, and correctly so |

### What the audit found

Three things surfaced while mapping cards to menus, and all three are now resolved in the plan:

1. **9.1-BE and 9.2-BE had no screen at all** — yet both ship manual-trigger endpoints behind HR permissions, and 9.1-BE promised an unassigned-employee report that existed only in logs. Fixed by adding **9.4-FE Attendance Jobs**.
2. **Leave and Corrections each need three and two nav items**, not the one each an early sidebar sketch assumed. Requests, Approvals, and Balances are separate routes in the cards, so they are separate items here.
3. **Monthly Approval and Monthly Freeze sit in different menus on purpose.** Approval is HR (Attendance), freeze is Finance (Payroll), and no seeded role holds both permissions. This looks like an inconsistency and is not one — it is the segregation of duties from spec §6.2.


## Suggested sprint shape

Assuming two backend and two frontend developers, points sized as above:

| Sprint | Content | Points |
|---|---|---|
| 1 | Part 0 complete; 1.1, 1.2 BE+FE | 26 |
| 2 | 1.3 BE+FE; 1.4a, 1.4b, 1.4-FE; 4.1 BE+FE | 31 |
| 3 | 2.1, 2.2 BE+FE; 7.0, 7.1 BE+FE (parallel track) | 28 |
| 4 | 3.1, 3.2 BE+FE; 7.2 BE+FE | 31 |
| 5 | 5.1, 5.2 BE+FE; 7.3, 7.4 BE+FE | 32 |
| 6 | 5.3a, 5.3b, 5.3-FE; 5.4, 5.5 BE+FE; 9.3 | 28 |
| 7 | 6.1, 6.2 BE+FE; 9.1, 9.2; Part K | 33 |
| 8 | 7.5, 7.6 BE+FE; 8.1 BE+FE; 9.4-FE | 32 |
| 9 | 8.0 BE+FE; 8.2a, 8.2b, 8.2-FE | 28 |
| 10 | 8.3a-BE, 8.3b-BE, 8.3a-FE, 8.3b-FE | 23 |

Sprint totals sum to 292, matching the index.

Part G (sprints 3–5 and 8, right-hand column) runs on an independent track and has no dependency on Parts A–F until sprint 8.

The plan grew from nine sprints to ten. 7.5 and 7.6 land in sprint 8 rather than earlier because 8.2a/8.2b-BE consume both, and pulling them forward would only idle the work; a team that wants advances live before payroll ships can move 7.5 into sprint 5 without touching anything else — it depends only on 7.0-BE and 4.1-BE.

## Open decisions

### Resolved — 27 July 2026

| Ref | Decision | Encoded in |
|---|---|---|
| **R1** | Attendance types, shifts, policies, and assignments live in the **Attendance module** behind a **single Configuration submenu**. The Configuration module is not involved. | 0.1-FE nav, 1.1–1.4 routes |
| **R2** | **A punch voids the leave for that date.** The day is calculated from attendance and the leave day is voided and refunded per day. | 3.2-BE pseudocode, 5.3a-BE (`leave_request_days`), 5.3b-BE (`voidLeaveDay`) |

R2 added one table (`leave_request_days`), one event (`LeaveDayVoided`), and 3 points to the leave-approval work — since the 28 July split those 3 points are card 5.3b-BE in full. It carries one sub-question that blocks nothing: whether the **half-day carve-out** stands (a half-day leave voids only on a full day's work) or whether any punch voids even a half-day leave. Reverting is one condition in `calculateDaily`.

### Resolved — 28 July 2026

| Ref | Decision | Encoded in |
|---|---|---|
| **R3** | **A salary advance is a payment, not a deduction.** Gross, tax, percentage components, and loan installments are all computed on the full gross; the advance is netted off at the very end into a new `net_payable`. Enabled per company, sized as a fixed amount or a percentage of gross. | 7.0-BE settings, 7.5-BE/FE, 8.2b-BE §6.3, 8.3a-BE settlement |
| **R4** | **Salary is paid across cash, cheque, and bank in a configured split**, set with the salary and frozen onto each payslip. Exactly one `residual` line guarantees the split totals the net payable. | 7.6-BE/FE, 8.2b-BE §6.4, 8.3b-BE multi-channel batches |

**R3 also settled how the money is handed over:** HR pays the advance by hand — cash, cheque, or a manual transfer — and records it. There is no advance disbursement batch and no automated payment path. Only advances recorded as `paid` are netted off a payslip, because deducting money the employee never received would underpay them.

Together R3 and R4 added four tables (`salary_advances`, `employee_salary_payment_modes`, `payslip_payment_allocations`, plus six columns on `payroll_settings` and three on `payslips`), three permissions, one approvable action, four events, four cards, and 39 points. They also **corrected an existing rule**: the pre-disbursement readiness check would have wrongly excluded employees paid entirely in cash, and is now channel-aware (8.3b-BE).

One sub-question, blocking nothing: whether an employee should be able to *request* their own advance through self-service, or whether HR always raises it on their behalf. This plan assumes **HR raises it** — 7.5 carries no self-service permission. Adding one later is a permission and a filtered list, not a redesign.

### Still open

| # | Decision | Blocks |
|---|---|---|
| 2 | Off-cycle payroll run workflow | 8.1 (column only; workflow deferred) |
| 3 | Notification strategy | 5.3, 5.5, 8.3 acceptance criteria |
| 4 | Biometric ingestion scope | 3.1 (`source` enum only) |
| 5 | `record-view-team` scope basis | 3.2 |

None of these blocks the start of Part 0 or Part A.
