# Attendance & Payroll — Implementation Spec

**Status:** Draft for review
**Date:** 27 July 2026
**Supersedes:** `Task_Cards_Dependency_Order.pdf` (26 July 2026)

This spec rewrites the source task-card document against the **actual ERPFlow schema and platform services**. The source document was written against a generic data model; roughly a third of its schema references do not exist in this codebase. Every table, endpoint, and service reference below has been verified against the repository.

---

## 0. Ground rules

These are non-negotiable platform conventions. Every story below assumes them; they are not repeated per card.

### 0.1 Employee identity

**`employee_id` always references `employee_personal_infos.id`.** There is no `employees` table.

Follow the existing Employee-module column convention — no cross-module foreign key constraints, index only:

```php
$table->unsignedBigInteger('company_id');
$table->unsignedBigInteger('employee_id');
$table->index(['company_id', 'employee_id']);
```

(See `backend/Modules/Employee/database/migrations/2026_07_19_110000_create_employee_salaries_table.php` for the reference pattern.)

### 0.2 Organisation scope

There is no `org_units` table. Organisation units are five separate Configuration-module tables: `branches`, `divisions`, `departments`, `teams`, `designations`.

Anywhere the source document said "employee or org_unit", use an explicit discriminator:

| Column | Type | Values |
|---|---|---|
| `scope_type` | `enum` | `company`, `branch`, `division`, `department`, `team`, `employee` |
| `scope_id` | `unsignedBigInteger` nullable | PK of the matching table; `NULL` when `scope_type = company` |

Resolution precedence, most-specific first:

```
employee > team > department > division > branch > company
```

This ordering is used by the Assignment Resolution Service (Story 2.1) and must not be duplicated anywhere else.

### 0.3 Multi-tenancy

Every table carries `company_id`. Every query is scoped through `App\Core\Tenancy\TenantContext` (`$this->tenantContext->id()`), never from user input. The company arrives on the request as the `X-Company-Id` header.

### 0.4 Module & URL layout

Two new nwidart modules. Route prefixes come from each module's `RouteServiceProvider`:

| Module | API prefix | Route name prefix |
|---|---|---|
| `Attendance` | `api/v1/attendance` | `api.attendance.` |
| `Payroll` | `api/v1/payroll` | `api.payroll.` |

**The source document's endpoints such as `/api/v1/attendance-types` are wrong** — the module prefix is mandatory. Correct form: `GET /api/v1/attendance/attendance-types`.

Every endpoint in this spec is written in its full, final form.

### 0.5 Permissions

Guard every route with `permission:<module>.<action>` middleware, exactly as the Employee module does:

```php
Route::middleware('permission:attendance.punch-create')->group(function () { ... });
```

Permission keys are created by seeding `actions` + `module_actions` and then calling `PermissionRepositoryInterface::syncForModule()`. See `backend/database/seeders/EmployeeModuleSeeder.php`. The full slug registry is Section 4.

### 0.6 Approval

**Do not build an approval engine.** Do not create `approval_flows`, and do not create the `approval_requests` shape shown in the source document's Story 5.5 / 8.3 cards — that shape does not exist.

The real runtime table is:

```
approval_requests(id, uuid, company_id, module_id, module_action_id,
                  workflow_version_id, requester_id, status, title,
                  correlation_id, submitted_at, completed_at, executed_at)
```

with `unique(company_id, correlation_id)`, and step tracking in `approval_request_steps` / `approval_request_approvers`, history in `approval_audits`, and the pending change body in `approval_payloads`.

Submission goes through `App\Platform\Services\ApprovalGateway::submit()` with an `ApprovalSubmissionData` DTO. Execution-on-approval goes through an `ApprovalExecutorInterface` implementation registered in `ApprovalExecutorRegistry`. Full integration detail is Section 5.

**`correlation_id` is unique per company**, so raw record IDs collide across types. Always prefix:

```
leave_request:{id}
correction_request:{id}
monthly_attendance:{id}
payroll_run:{id}
salary_advance:{id}
```

### 0.7 Definition of Done

Applies to every story; not repeated per card.

**Backend**
- Controller → Service (interface-bound) → Repository (interface-bound) layering, matching the Employee module.
- Form Request for validation; API Resource for output.
- `company_id` scoping enforced in the repository, never in the controller.
- Feature tests for the happy path and every documented error case; unit tests for all calculation functions.
- Activity logged via `ActivityLogService` for create/update/delete.
- Request/response examples added to `api collection/<Module>/…` as `.yml` (this project does **not** use OpenAPI/Swagger).
- Code reviewed and merged.

**Frontend**
- Module registered in `frontend/src/app/registerModules.ts`; nav entries added to `frontend/src/modules/core/config/navigation.ts` with the correct `permission` key.
- Routes wrapped in `ProtectedRoute` → `AppLayout` → `RequirePermissionRoute`.
- API layer under `modules/<module>/api/*.ts`; TanStack Query for server state.
- Loading, empty, and error states implemented; validation mirrors the backend rules.
- Responsive at desktop and tablet widths.

### 0.8 Error contract

| Condition | Status |
|---|---|
| Duplicate unique key within company | 409 |
| Unknown id | 404 |
| Missing permission | 403 |
| Validation failure | 422 with field-level messages |
| State-machine violation (e.g. freeze before approve) | 409 |

---

## 1. Corrections applied to the source document

Recorded so reviewers can see what changed and why.

| # | Source document | Correction |
|---|---|---|
| 1 | `assignments.scope_id` → "employees.id or org_units.id" | `employee_personal_infos.id` or one of five org tables, via `scope_type` enum (§0.2) |
| 2 | Story 5.5 / 8.3 print an invented `approval_requests` schema (`approvable_type`, `approval_flow_id`, `current_step`, `history json`) | Removed. Real schema + `ApprovalGateway` (§5) |
| 3 | Pseudocode `ApprovalEngine.route(request, flow_type:'leave')` | `ApprovalGateway::submit(ApprovalSubmissionData)` + registered executor |
| 4 | `correlation_id` = raw record id | Prefixed correlation id (§0.6) — the column is unique per company |
| 5 | Endpoints like `/api/v1/attendance-types` | Module prefix required: `/api/v1/attendance/attendance-types` |
| 6 | Story 1.1 lists only `PUT`/`DELETE`; 1.2 has no `POST`; 7.5 only `PUT` | Full CRUD specified |
| 7 | `attendance_types` is user-configurable but the calculation engine uses `PRESENT`/`LATE`/… constants; AC says "system defaults cannot be deleted" with no supporting column | Added immutable `system_code` + `is_system` |
| 8 | `OT_MULTIPLIER` and `hourlyRate()` undefined | Defined in `payroll_settings` (Story 7.0) |
| 9 | `payslips.attendance_summary_ref` described as `monthly_attendance_approvals.id`, but 6.3 mandates snapshot-only reads | Renamed `attendance_snapshot_id` → `attendance_snapshots.id` |
| 10 | `approveLeave` marks every date in range as LEAVE, ignoring the holiday/weekend filter that `applyLeave` applies | Same working-day filter on both paths |
| 11 | Punches "immutable", yet `applyCorrectionToPunches` "adjusts punch rows" | Corrections insert superseding rows; originals never mutated (§Story 5.5) |
| 12 | `attendance_snapshots` (6.3) has FK to `payroll_runs`, which is created in 8.1 | Moved after payroll run creation — now Story 8.0 |
| 13 | 7.3 / 7.4 / 7.7 propose `/employee-salaries`, `/employee-tax-profiles`, `/employee-bank-accounts` | These already exist in the Employee module. Payroll consumes them; no duplicate endpoints (§7 Part G) |
| 14 | `monthly_attendance_approvals.unresolved_flag` used in pseudocode, absent from schema | Column added |
| 15 | Story cross-references ("Story 8", "Story 10.1", "Story 12", "Story 9.5") from an older numbering scheme | Renumbered consistently; mapping in §8 |
| 16 | DoD: "API documented (OpenAPI/Swagger)" | `api collection/*.yml` (§0.7) |
| 17 | Timezone resolved by 2.1, no timezone column anywhere | `companies.timezone` + `shifts.timezone` (Story 0.2 / 1.2) |
| 18 | No module scaffolding, no permission slugs, no schedulers, no absent-marking job, no seeders | Added as Part 0 and Part J |

---

## 2. Build order

Dependency-ordered. Nothing is built before what it depends on.

| Part | Stories | Depends on |
|---|---|---|
| **0 — Bootstrap** | 0.1 Module scaffolding · 0.2 Platform prerequisites · 0.3 Action & permission registry | — |
| **A — Configuration** | 1.1 Attendance types · 1.2 Shifts · 1.3 Policies (leave & holiday) · 1.4 Assignments | Part 0 |
| **B — Resolution** | 2.1 Assignment resolution service · 2.2 Policy snapshotting | A |
| **C — Runtime** | 3.1 Multi-punch check-in/out · 3.2 Daily attendance summary | B |
| **D — Approval wiring** | 4.1 Register module actions & executors | Part 0 (can run parallel to A–C) |
| **E — Business layer** | 5.1 Leave balances · 5.2 Leave application · 5.3 Leave approval · 5.4 Correction request · 5.5 Correction approval | C, D |
| **F — Monthly closing** | 6.1 Monthly approval · 6.2 Freeze state | E |
| **G — Payroll config** | 7.0 Payroll settings · 7.1 Salary structures · 7.2 Structure components · 7.3 Tax slabs · 7.4 Deductions & loans · 7.5 Salary advances · 7.6 Payment mode split | Part 0 (parallel to A–F); 7.5 also needs D |
| **H — Payroll execution** | 8.1 Payroll run creation · 8.0 Attendance snapshot · 8.2 Payslip generation · 8.3 Approval & disbursement | F, G |
| **J — Automation** | 9.1 Nightly attendance close · 9.2 Leave accrual & carry-forward · 9.3 Seeders · 9.4 Job monitoring UI | C, E |

Parts A–F (Attendance module) and G (Payroll module) can be worked by two people in parallel after Part 0. They converge at Part H.

**Stories vs cards.** This section lists *stories*. Five of them are delivered as two task cards each, split on 28 July 2026 so that no card exceeds 8 points — 1.4, 5.3, 8.2, 8.3-BE, and 8.3-FE. The story numbering here is unchanged; see the task-cards document for the `a` / `b` breakdown. One ordering constraint escapes into the spec: **8.2b must be built before 8.3b**, because 8.2a writes a provisional `net_payable`.

> Story 8.0 is numbered out of sequence deliberately: it belongs to the Payroll module and depends on `payroll_runs` existing, so it is built between 8.1 and 8.2.

---

## 3. Data model

### 3.1 Attendance module

**`attendance_types`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `name` | varchar(100) | |
| `code` | varchar(50) | unique per company |
| `system_code` | varchar(50) nullable | **immutable.** One of `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 | |
| `counts_as_working_day` | boolean | |
| `eligible_for_payroll` | boolean | |
| `color` / `icon` | varchar(30) / varchar(50) | |
| `is_system` | boolean default false | seeded rows only; blocks delete |
| `status` | varchar(20) default `Active` | |
| `created_by` / `updated_by` | unsignedBigInteger nullable | |
| | | `unique(company_id, code)`, `unique(company_id, system_code)`, `index(company_id, status)` |

The calculation engine resolves statuses by `system_code`, never by `name` or `id`. HR may rename or recolour a system type but may not change its `system_code`, delete it, or create a second type with the same `system_code`.

**`shifts`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `name` | varchar(150) | |
| `code` | varchar(50) | unique per company |
| `start_time` / `end_time` | time | |
| `timezone` | varchar(64) nullable | IANA name; falls back to `companies.timezone` |
| `break_minutes` | int default 0 | |
| `working_hours` | decimal(5,2) | expected paid hours |
| `grace_minutes` | int default 0 | |
| `min_hours_present` | decimal(5,2) | |
| `min_hours_half_day` | decimal(5,2) | |
| `working_days` | json | `[1,2,3,4,5]`, ISO-8601 day numbers (1 = Monday) |
| `is_overnight` | boolean default false | |
| `status` | varchar(20) default `Active` | |
| `created_by` / `updated_by` | unsignedBigInteger nullable | |

**`policies`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `policy_type` | enum(`leave`,`holiday`) | |
| `name` | varchar(150) | |
| `code` | varchar(50) | unique per `(company_id, policy_type)` |
| `effective_date` | date | |
| `config` | json | type-specific; shape below |
| `status` | enum(`Active`,`Inactive`,`Archived`) | |
| `created_by` / `updated_by` | unsignedBigInteger nullable | |

`config` for `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` for `policy_type = holiday`:

```json
{
  "holidays": [
    { "name": "Victory Day", "date": "2026-12-16", "type": "public", "recurring": true, "description": "" }
  ]
}
```

**`assignments`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `assignable_type` | enum(`shift`,`policy`) | |
| `assignable_id` | unsignedBigInteger | `shifts.id` or `policies.id` |
| `assignable_subtype` | varchar(20) nullable | `leave` / `holiday` when `assignable_type = policy`; denormalised so overlap checks can distinguish leave from holiday assignments |
| `scope_type` | enum | §0.2 |
| `scope_id` | unsignedBigInteger nullable | `NULL` when `scope_type = company` |
| `effective_date` | date | |
| `end_date` | date nullable | |
| `status` | varchar(20) default `Active` | |
| `created_by` | unsignedBigInteger nullable | |
| | | `index(company_id, scope_type, scope_id)`, `index(company_id, assignable_type, effective_date)` |

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. Enforced in the service inside a transaction with `SELECT … FOR UPDATE` — a DB constraint cannot express it.

**`attendance_punches`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `attendance_date` | date | logical day; may differ from `punch_time`'s date for overnight shifts |
| `punch_type` | enum(`in`,`out`) | |
| `punch_time` | datetime | server time, UTC |
| `source` | enum(`web`,`mobile`,`biometric`,`api`,`manual`) | |
| `ip_address` | varchar(45) nullable | |
| `device_info` | varchar(255) nullable | |
| `remarks` | varchar(255) nullable | |
| `sequence_no` | int | order 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 | |
| | | `index(company_id, employee_id, attendance_date)` |

Rows are append-only. There is no `updated_at` and no update endpoint. A correction inserts new rows and stamps `superseded_by_id` on the ones they replace; all calculation queries filter `whereNull('superseded_by_id')`.

**`attendance_records`** — one row per employee per day

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `attendance_date` | date | |
| `shift_id` | unsignedBigInteger nullable | |
| `attendance_type_id` | unsignedBigInteger | |
| `first_check_in` | datetime nullable | |
| `last_check_out` | datetime nullable | |
| `total_working_hours` | decimal(6,2) default 0 | |
| `overtime_hours` | decimal(6,2) default 0 | |
| `late_minutes` | int default 0 | |
| `early_leave_minutes` | int nullable | |
| `punch_count` | int default 0 | |
| `policy_snapshot` | json | Story 2.2; never null on a calculated row |
| `is_locked` | boolean default false | set when the month is approved |
| `calculated_at` | timestamp nullable | |
| | | `unique(company_id, employee_id, attendance_date)` |

**`correction_requests`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `attendance_date` | date | |
| `request_type` | enum(`missing_in`,`missing_out`,`incorrect_time`,`wrong_status`,`other`) | |
| `requested_check_in` / `requested_check_out` | datetime nullable | |
| `reason` | text | |
| `attachment_path` | varchar(255) nullable | |
| `status` | enum(`pending`,`approved`,`rejected`,`cancelled`) | |
| `decided_by` / `decided_at` | unsignedBigInteger nullable / timestamp nullable | |
| `decision_reason` | text nullable | mandatory on reject |
| | | `index(company_id, employee_id, attendance_date)` |

**`leave_balances`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `leave_policy_id` | unsignedBigInteger | `policies.id` where `policy_type = leave` |
| `year` | smallint | |
| `entitled_days` / `used_days` / `carried_forward_days` / `encashed_days` | decimal(6,2) default 0 | |
| | | `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`** *(new — not in the source document)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `leave_balance_id` | unsignedBigInteger | |
| `entry_type` | enum(`accrual`,`carry_forward`,`consumption`,`reversal`,`encashment`,`manual_adjustment`) | |
| `days` | decimal(6,2) | signed |
| `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 | |
| `created_at` | timestamp | |

Story 5.1 in the source document exposes `PATCH …/adjust` and a "history" endpoint with nothing to read from. Every mutation to `leave_balances` writes a ledger row in the same transaction; the sum of ledger rows must equal the balance columns (assertable in tests).

**`leave_requests`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `leave_policy_id` | unsignedBigInteger | |
| `start_date` / `end_date` | date | |
| `duration_type` | enum(`full_day`,`half_day_first`,`half_day_second`) | |
| `total_days` | decimal(5,2) | auto-calculated |
| `reason` | text | |
| `attachment_path` | varchar(255) nullable | |
| `status` | enum(`pending`,`approved`,`rejected`,`cancelled`) | |
| `decided_by` / `decided_at` / `decision_reason` | as `correction_requests` | |
| | | `index(company_id, employee_id, start_date)` |

**`leave_request_days`** *(new — required by the punch-voids-leave rule)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `leave_request_id` | unsignedBigInteger | |
| `company_id` / `employee_id` | unsignedBigInteger | denormalised for scoped queries |
| `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` | |
| `voided_reason` | varchar(100) nullable | `punched` when voided by attendance |
| `voided_at` | timestamp nullable | |
| | | `unique(company_id, employee_id, leave_date, leave_request_id)` · `index(leave_request_id, status)` |

A leave request spans a range, but the punch-voids-leave rule operates on a **single date**. Without per-day rows there is nowhere to record that day 3 of a 5-day leave was cancelled, and no way to refund exactly one day. Rows are created on approval (§Story 5.3), one per working day the range actually consumes — holidays and non-working days produce no row, so `sum(day_value)` always equals `leave_requests.total_days`.

**`monthly_attendance_approvals`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `month` / `year` | tinyint / smallint | |
| `total_present_days`, `total_absent_days`, `total_leave_days`, `total_unpaid_leave_days`, `total_half_days`, `total_working_hours`, `total_overtime_hours` | decimal(7,2) | |
| `total_late_count` | int | |
| `unresolved_flag` | boolean default false | pending correction/leave or missing check-out exists |
| `status` | enum(`pending`,`approved`,`rejected`) | |
| `is_locked` | boolean default false | |
| `ready_for_payroll` | boolean default false | |
| `approved_by` / `approved_at` | unsignedBigInteger nullable / datetime nullable | |
| `frozen_at` / `frozen_by` | datetime nullable / unsignedBigInteger nullable | |
| `unfreeze_reason` | varchar(255) nullable | |
| | | `unique(company_id, employee_id, month, year)` |

`total_unpaid_leave_days` is added because payslip pro-rating needs unpaid days separated from paid leave — the source document's pseudocode calls `unpaidLeaveDays(snapshot)` against a field that does not exist.

### 3.2 Payroll module

**`payroll_settings`** *(new — Story 7.0)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | unique |
| `overtime_multiplier` | decimal(4,2) default 1.50 | |
| `overtime_rate_base` | enum(`gross`,`basic`) default `basic` | |
| `standard_monthly_hours` | decimal(6,2) default 208 | divisor for the hourly rate |
| `hourly_rate_method` | enum(`fixed_monthly_hours`,`working_days_x_shift_hours`) default `fixed_monthly_hours` | |
| `prorate_method` | enum(`working_days`,`calendar_days`) default `working_days` | |
| `round_net_pay_to` | decimal(4,2) default 1.00 | |
| `advance_enabled` | boolean default false | **the company-wise switch** — false hides the menu and 409s the 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 `sum(advances) / basis_gross` for a month |
| `advance_max_amount` | decimal(12,2) nullable | absolute ceiling; null = no absolute cap |
| `advance_requires_approval` | boolean default true | false lets `payroll.advance-manage` holders pay directly |
| `updated_by` | unsignedBigInteger nullable | |

Hourly rate:

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

**`salary_structure_components`** *(new)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `salary_structure_id` | unsignedBigInteger | `salary_structures.id` |
| `component_name` | varchar(100) | |
| `component_code` | varchar(50) | unique per structure |
| `component_type` | enum(`earning`,`deduction`) | |
| `is_basic` | boolean default false | at most one per structure |
| `calculation_type` | enum(`fixed`,`percentage`) | |
| `value` | decimal(12,2) | amount, or percent (25.00 = 25%) |
| `percentage_base` | enum(`gross`,`basic`) nullable | required when `calculation_type = percentage` |
| `is_taxable` | boolean default true | |
| `prorated` | boolean default true | whether unpaid days reduce it |
| `display_order` | int default 0 | |
| `status` | varchar(20) default `Active` | |

`prorated` is added because fixed allowances (e.g. a mobile-bill reimbursement) should not shrink with absence; the source pseudocode pro-rates every earning unconditionally.

**`tax_slabs`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `name` | varchar(150) | |
| `effective_year` | smallint | |
| `slabs` | json | `[{min_income, max_income, rate}]`, `max_income: null` for the top bracket |
| `status` | varchar(20) | |
| | | `unique(company_id, effective_year, status)` cannot be expressed directly — enforce "one Active per year" in the service |

**`employee_deductions`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `type` | enum(`loan`,`advance`,`fine`,`other`) | |
| `total_amount` / `remaining_balance` / `installment_amount` | decimal(12,2) | |
| `start_month` / `start_year` | tinyint / smallint | |
| `status` | enum(`active`,`completed`,`cancelled`) | |
| `created_by` / `updated_by` | unsignedBigInteger nullable | |

**`employee_deduction_entries`** *(new)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `employee_deduction_id` | unsignedBigInteger | |
| `payroll_run_id` / `payslip_id` | unsignedBigInteger | |
| `amount` | decimal(12,2) | |
| `created_at` | timestamp | |
| | | `unique(employee_deduction_id, payroll_run_id)` |

Without this, regenerating a draft payslip decrements `remaining_balance` twice. The unique key makes installment application idempotent per run; regeneration reverses the prior entry and re-applies.

**`salary_advances`** *(new — Story 7.5)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `month` / `year` | tinyint / smallint | the payroll period this is advanced **against** |
| `employee_salary_id` | unsignedBigInteger | the salary row the request was priced from |
| `request_method` | enum(`fixed`,`percentage`) | |
| `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`) | |
| `reason` | text nullable | |
| `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` | datetime nullable | set when HR records the handover |
| `paid_by` | unsignedBigInteger nullable | |
| `settled_payslip_id` | unsignedBigInteger nullable | set when the payslip nets it off |
| `settled_at` | datetime nullable | |
| `created_by` / `approved_by` | unsignedBigInteger nullable | |
| | | `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 over several months as installments against future salaries. A `salary_advances` row is a *part-payment of the employee's own salary for that same month*: no installments, no interest, no carry into another month, and it is settled inside that month's payslip. They are separate tables because merging them makes both the loan report and the advance report wrong.

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

**Advance money is disbursed by HR outside the system** *(decided 28 July 2026)*. There is no advance disbursement batch. HR hands over cash / cheque / a manual transfer and records it via `POST /…/{id}/record-payment`, which sets `payment_channel`, `payment_reference`, `paid_at`, `paid_by` and moves the row to `paid`. Only rows in status `paid` are netted off by the payslip generator — `approved`-but-unpaid rows are ignored, because deducting money the employee never received would underpay them.

**`employee_salary_payment_modes`** *(new — Story 7.6)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `employee_salary_id` | unsignedBigInteger | `employee_salaries.id` — the split is versioned with the salary revision |
| `channel` | enum(`cash`,`cheque`,`bank`,`mobile_banking`) | |
| `allocation_type` | enum(`fixed`,`percentage`,`residual`) | |
| `value` | decimal(12,2) nullable | amount, or percent; **null when `residual`** |
| `employee_bank_account_id` | unsignedBigInteger nullable | required when `channel` is `bank` or `mobile_banking` |
| `display_order` | int default 0 | order of application |
| | | `index(company_id, employee_salary_id)`, `unique(employee_salary_id, channel)` |

Rules, enforced in the service because they span rows:
- **Exactly one `residual` row per `employee_salary_id`.** This is what makes the split always total the net payable exactly, whatever the deductions and the advance turned out to be. A configuration with no residual row is rejected.
- `fixed` + `percentage` rows must not exceed `gross_salary` at configuration time (a soft warning, since the actual net is lower).
- `percentage` values must be in `(0, 100]`.
- An employee with no rows at all falls back to a single implicit `residual` row on their primary bank account — the current behaviour, so existing data keeps working.

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.

**`payslip_payment_allocations`** *(new — Story 8.2)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `payslip_id` / `employee_id` | unsignedBigInteger | |
| `channel` | enum(`cash`,`cheque`,`bank`,`mobile_banking`) | |
| `amount` | decimal(12,2) | |
| `employee_bank_account_id` | unsignedBigInteger nullable | |
| `source` | enum(`config`,`manual_override`) default `config` | |
| | | `unique(payslip_id, channel)` |

The **resolved** split, frozen when the payslip is generated, 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 reads only from here. `sum(amount)` must equal `payslips.net_payable` — asserted at generation, re-asserted before batch creation.

**`payroll_runs`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | unsignedBigInteger | |
| `month` / `year` | tinyint / smallint | |
| `run_type` | enum(`regular`,`off_cycle`) default `regular` | |
| `status` | enum(`draft`,`processing`,`pending_approval`,`approved`,`paid`,`locked`,`failed`) | |
| `total_employees` | int default 0 | |
| `total_amount` | decimal(15,2) default 0 | |
| `processed_at` | datetime nullable | |
| `approved_by` | unsignedBigInteger nullable | |
| `created_by` | unsignedBigInteger nullable | |
| | | `unique(company_id, month, year, run_type)` — allows one off-cycle correction run alongside the regular run |

**`attendance_snapshots`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `payroll_run_id` | unsignedBigInteger | |
| `month` / `year` | tinyint / smallint | |
| `present_days`, `absent_days`, `leave_days`, `unpaid_leave_days`, `half_days`, `overtime_hours`, `working_hours` | decimal(7,2) | copied from the frozen monthly row |
| `late_count` | int | |
| `source_monthly_approval_id` | unsignedBigInteger | |
| `snapshot_taken_at` | timestamp | |
| | | `unique(payroll_run_id, employee_id)` |

Immutable. No update endpoint, no `updated_at`.

**`payslips`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `employee_id` | unsignedBigInteger | |
| `payroll_run_id` | unsignedBigInteger | |
| `attendance_snapshot_id` | unsignedBigInteger | **`attendance_snapshots.id`** |
| `employee_salary_id` | unsignedBigInteger | which salary row was used |
| `gross_earnings` / `total_deductions` / `net_pay` | decimal(12,2) | |
| `advance_paid` | decimal(12,2) default 0 | *(new)* sum of `paid` advances for the period |
| `net_payable` | decimal(12,2) | *(new)* `net_pay - advance_paid`, floored at 0 — **this is what is disbursed** |
| `advance_carry_forward` | decimal(12,2) default 0 | *(new)* excess when the advance exceeded the earned net |
| `earnings_breakdown` / `deductions_breakdown` | json | |
| `currency_code` | char(3) | |
| `status` | enum(`draft`,`finalized`,`paid`) | |
| `needs_review` | boolean default false | net pay would have gone negative, or an advance was over-paid |
| `generated_at` | timestamp | |
| | | `unique(payroll_run_id, employee_id)` |

`net_pay` keeps its original meaning — the employee's **earned** net for the month. Tax certificates, salary reports, and year-end statements read `net_pay`, not `net_payable`. The advance is a payment already made against it, not a reduction of what was earned, so it never touches `net_pay`, `gross_earnings`, or `deductions_breakdown`.

**`disbursement_batches`**

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` / `payroll_run_id` | unsignedBigInteger | |
| `batch_reference` | varchar(100) | |
| `payout_channel` | enum(`bank`,`mobile_banking`,`cash`,`cheque`) | **widened** — one batch per channel |
| `total_amount` | decimal(15,2) | |
| `status` | enum(`pending`,`sent`,`confirmed`,`failed`) | |
| `sent_at` | datetime nullable | |
| `failure_reason` | text nullable | |

`cash` and `cheque` batches are **register batches**, not transmissions: nothing is sent anywhere. They move `pending → confirmed` item by item as each employee acknowledges receipt, and exist so that a cash payroll is auditable and reconcilable in the same shape as a bank payroll.

**`disbursement_batch_items`** *(new)*

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `disbursement_batch_id` / `payslip_id` / `employee_id` | unsignedBigInteger | |
| `payslip_payment_allocation_id` | unsignedBigInteger | *(new)* the frozen allocation this item pays |
| `employee_bank_account_id` | unsignedBigInteger **nullable** | **widened** — null for `cash` and `cheque` |
| `amount` | decimal(12,2) | |
| `payment_reference` | varchar(100) nullable | *(new)* cheque no. / txn id / voucher no. |
| `acknowledged_by` / `acknowledged_at` | unsignedBigInteger / datetime nullable | *(new)* cash receipt acknowledgement |
| `status` | enum(`pending`,`sent`,`confirmed`,`failed`) | |
| `failure_reason` | text nullable | |
| | | `unique(payslip_payment_allocation_id)` |

Required for the "retry a failed batch without duplicating payment" acceptance criterion — retry re-sends only items whose status is `failed`. The unique key on the allocation is what makes a payslip split across three channels produce exactly three items, no matter how many times disbursement is retried.

A payslip becomes `paid` only when **every** item across **all** its batches is `confirmed` — a bank leg confirming while the cash leg is still outstanding does not close the payslip.

---

## 4. Permission registry

Seeded in Story 0.3. `actions.slug` values are shared platform-wide (see `ActionSeeder`), so reuse existing slugs where they fit and add new ones only when needed.

### Attendance module (`attendance.*`)

| Permission key | Grants |
|---|---|
| `attendance.menu-view` | see the Attendance section |
| `attendance.config-manage` | CRUD attendance types, shifts, policies |
| `attendance.assignment-manage` | create/edit/end assignments, bulk assign |
| `attendance.assignment-preview` | assignment resolution diagnostic tool |
| `attendance.punch-create` | punch in/out for self |
| `attendance.punch-create-others` | punch on behalf of another employee (`source=manual`) |
| `attendance.record-view-own` | own attendance records |
| `attendance.record-view-team` | records within the caller's reporting scope |
| `attendance.record-view-all` | all records in the company |
| `attendance.record-export` | export daily summaries |
| `attendance.record-recalculate` | "recalculate with current policy" override |
| `attendance.correction-create` | submit a correction request |
| `attendance.correction-approve` | approve/reject corrections |
| `attendance.correction-override-lock` | approve a correction against a locked period |
| `attendance.leave-apply` | submit a leave request |
| `attendance.leave-approve` | approve/reject leave |
| `attendance.leave-balance-view` | view others' balances |
| `attendance.leave-balance-adjust` | manual balance adjustment |
| `attendance.monthly-view` | monthly approval grid |
| `attendance.monthly-approve` | approve/reject a month, bulk approve |
| `attendance.monthly-unlock` | unlock an approved month |

### Payroll module (`payroll.*`)

| Permission key | Grants |
|---|---|
| `payroll.menu-view` | see the Payroll section |
| `payroll.settings-manage` | payroll settings, tax slabs |
| `payroll.structure-manage` | salary structures and components |
| `payroll.deduction-manage` | loans, recurring advances, fines |
| `payroll.advance-manage` | request/edit/cancel salary advances, record HR handover |
| `payroll.advance-approve` | approve/reject a salary advance |
| `payroll.payment-mode-manage` | configure an employee's cash / cheque / bank split |
| `payroll.run-create` | create a payroll run, generate payslips |
| `payroll.run-override-readiness` | create a run despite employees not ready |
| `payroll.run-approve` | approve/reject a payroll run |
| `payroll.month-freeze` | freeze/unfreeze a month — **must not be granted to the same role as `attendance.monthly-approve`** (segregation of duties) |
| `payroll.month-unfreeze-paid` | unfreeze a month whose run is already Paid |
| `payroll.disburse` | create/send/retry disbursement batches |
| `payroll.payslip-view-own` | own payslip |
| `payroll.payslip-view-all` | all payslips |

Self-service reads (`attendance.record-view-own`, `payroll.payslip-view-own`, `attendance.punch-create`, `attendance.correction-create`, `attendance.leave-apply`) belong to a seeded `employee` role that every user gets by default.

---

## 5. Approval integration (Story 4.1)

### 5.1 Module actions to register

Five approvable actions. Each is a distinct `module_actions` row so each can carry its own 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` |

All five are seeded with `requires_approval = true`, then an `approval_settings` row per company wires each to a workflow. Follow `ConfigurationApprovalSeeder` for the shape.

`advance-approve` is seeded like the rest, but `payroll_settings.advance_requires_approval = false` short-circuits it before the gateway is ever called: the service marks the advance `approved` directly and records who did it. This is a *company policy* switch and is deliberately separate from `approval_settings.approval_enabled`, which is the *platform* switch — either one being off produces an immediately-approved advance.

### 5.2 Submission

Every approvable action submits through the gateway. Modules never write to `approval_requests` directly.

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

`ApprovalGateway::submit()` returns immediately (executing `onApproved` synchronously) when `approval_settings.approval_enabled = 0` for that action. This is the documented bypass path — **it is not an error.** The source document's "409 Conflict when approval_enabled=0" rule is wrong and is dropped.

### 5.3 Executors

One executor per entity type, implementing `ApprovalExecutorInterface`, registered in the module service provider:

```php
$this->app->make(ApprovalExecutorRegistryContract::class)
    ->register('leave_request', LeaveRequestExecutor::class);
```

Model on `Modules\Employee\Approval\EmployeeBankAccountExecutor`. The executor is the **only** place a business record transitions to its approved state, so the synchronous-bypass path and the workflow path share identical logic.

| Entity type | Executor does |
|---|---|
| `attendance_correction` | insert superseding punches, recalculate the day, mark request approved |
| `leave_request` | deduct balance (+ ledger row), stamp leave on working days in range, mark approved |
| `monthly_attendance` | set `status=approved`, lock the month's `attendance_records`, set `ready_for_payroll=true` |
| `payroll_run` | set run `status=approved`, finalize its payslips |
| `salary_advance` | set advance `status=approved`, stamp `approved_by` — **does not pay it**; HR records the handover separately |

### 5.4 Reading approval state

The business record carries its own `status` for querying and display. Full step/approver history is read from the platform's existing approval endpoints, keyed by `correlation_id` — no history JSON column is duplicated on business tables.

---

## 6. Calculation contracts

The three functions below are the correctness core of the module. Each lives in a single service and is covered by unit tests.

### 6.1 `AssignmentResolver::resolve(employeeId, date): ResolvedContext` — Story 2.1

Returns the effective shift, leave policies, holiday calendar, and timezone for one employee on one date.

```
scopes = [
  ['employee', employeeId],
  ['team',       teamIdOf(employeeId, date)],
  ['department', departmentIdOf(employeeId, date)],
  ['division',   divisionIdOf(employeeId, date)],
  ['branch',     branchIdOf(employeeId, date)],
  ['company',    null],
]

for each 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; break

if resolved[shift] is missing:
    return ResolvedContext.unassigned(reason: 'no_shift')     # never silently defaulted
```

- Org-unit membership is read from `employee_organization_assignments` **as of `date`**, not as of today — a transferred employee's history must resolve against the unit they were in at the time.
- Result cached per `(company_id, employee_id, date)`. Invalidated by the events in §6.4.
- An unassigned date is an explicit result, surfaced as 422 `unassigned` on the API, and skipped (not defaulted) by the nightly job.

### 6.2 `AttendanceCalculator::calculateDaily(employeeId, date)` — Story 3.2

```
punches = punches(employeeId, date) where superseded_by_id is null order by punch_time asc
context = AssignmentResolver.resolve(employeeId, date)

if context.unassigned:                       return  # no record written; 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(employeeId, 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 -> attendance is calculated from punches; leave may be voided below

shift           = context.shift
first_check_in  = punches[0].punch_time
last            = punches[last]
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 (company rule) ---
if leave_day:
    if leave_day.day_value == 1.00 or working_hours >= shift.min_hours_present:
        LeaveService.voidLeaveDay(leave_day, reason: 'punched')   # refunds the balance
        emit LeaveDayVoided(leave_day)
    else:
        status = half_day        # half-day leave stands; the worked half is the other half

upsert AttendanceRecord{ …, policy_snapshot: {
    shift_id, grace_minutes, working_hours, min_hours_present,
    min_hours_half_day, working_days, timezone, resolved_at
}}
emit AttendanceCalculated(record)
```

Three rules encoded above, each fixing a defect in the source pseudocode:

1. Holiday and weekend are checked **before** "no punches ⇒ absent". The source marks every weekend as ABSENT, which would wreck the monthly totals.
2. `missing_check_out` is checked **first**, not last. In the source it sits below `half_day`, so an employee who worked 8 hours and forgot to check out is recorded as `present` with a null checkout.
3. **A punch beats an approved leave.** Attendance is calculated from the punches and the leave day is voided and refunded. The source returns LEAVE on any approved leave and never looks at the punches, so an employee who came in anyway loses the day's balance and is recorded as absent from work.

**Half-day carve-out.** Applying "any punch voids the leave" literally to a half-day leave would make half-day leave unusable — the employee always punches for the half they work. So a half-day leave is voided only when the punches show a **full** day's work (`working_hours >= min_hours_present`); otherwise the leave stands and the day is Half Day. Flagged in §9 for confirmation: reverting to the literal rule is a one-line change.

Recalculation uses the stored `policy_snapshot` by default. Re-resolving live policy happens only via the explicit `attendance.record-recalculate` action, and never on a locked record.

### 6.3 `PayslipGenerator::generate(payrollRunId, employeeId)` — Story 8.2

```
snapshot  = AttendanceSnapshot.for(payrollRunId, employeeId)      # frozen; required
salary    = activeSalaryAsOf(employeeId, run.period_end)          # latest effective_date, status Active
structure = SalaryStructure(salary.salary_structure_id)
settings  = PayrollSettings(company)

working_days = workingDaysInMonth(employeeId, run.month, run.year)   # from resolved shift + holidays
unpaid_days  = snapshot.absent_days + snapshot.unpaid_leave_days
pro_rate     = working_days > 0 ? (working_days - unpaid_days) / working_days : 0

# ---- 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)
    amount = c.prorated ? raw * pro_rate : raw
    earnings[c.component_code] = round2(amount)

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

gross = sum(earnings)

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

# statutory tax
tax_exempt = employeeTaxProfile(employeeId)?.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(company, run.year)          # required; fail the row if missing
    deductions['income_tax'] = round2(calculateSlabTax(taxable_monthly * 12, slab.slabs) / 12)

# recurring deductions / loans — idempotent per run
for d in activeDeductions(employeeId, run.month, run.year):
    amt = DeductionApplier.apply(d, run, projectedNet: gross - sum(deductions))
    if amt > 0: deductions[d.type] = amt

total_deductions = sum(deductions)
net_pay          = gross - total_deductions

if net_pay < 0:
    net_pay = 0; needs_review = true       # payslip still written, flagged for HR

# ---- salary advance settlement (Story 7.5) ----
# Advances are NOT deductions. Tax, percentage components and loan installments
# above were all computed on the full gross, exactly as if no advance existed.
# The advance is money already handed over, netted off at the very end.
advances     = salaryAdvances(employeeId, run.month, run.year, status: 'paid')
advance_paid = round2(sum(a.amount for a in advances))

net_payable          = max(0, net_pay - advance_paid)
advance_carry_fwd    = max(0, advance_paid - net_pay)
if advance_carry_fwd > 0: needs_review = true

upsert Payslip{ payroll_run_id, employee_id, attendance_snapshot_id: snapshot.id,
                employee_salary_id: salary.id, gross_earnings: gross, total_deductions,
                net_pay, advance_paid, net_payable, advance_carry_forward: advance_carry_fwd,
                earnings_breakdown: earnings, deductions_breakdown: deductions,
                currency_code: salary.currency_code, status: draft, needs_review }

PaymentAllocator.allocate(payslip)        # §6.4 — freezes the cash/cheque/bank split
```

Worked example — gross 30,000, deductions 3,500, advance 15,000 already handed over:

| | |
|---|---|
| `gross_earnings` | 30,000.00 |
| `total_deductions` | 3,500.00 |
| `net_pay` | **26,500.00** — earned; this is what tax and reports use |
| `advance_paid` | 15,000.00 |
| `net_payable` | **11,500.00** — this is what disbursement pays |

Settlement rules:

- Only advances in status **`paid`** are netted. An `approved`-but-unhanded-over advance is ignored — netting off money the employee never received would underpay them. It carries to whichever run first sees it as `paid`.
- On run **approval** (not draft generation), each netted advance moves to `settled` with `settled_payslip_id` and `settled_at` stamped. Draft regeneration must be repeatable, so nothing is stamped while the payslip is a draft.
- When `advance_carry_forward > 0` — the employee took more advance than they ended up earning, typically after heavy unpaid absence — run approval creates an `employee_deductions` row of type `advance` for the excess, recoverable from the following month. `net_payable` is 0 and the payslip is flagged `needs_review` so HR sees it before approving.
- Regeneration of a draft reverses nothing on the advances themselves (they were never mutated) and simply recomputes `advance_paid` from current `paid` rows.

Changes from the source pseudocode:

- **Tax is computed on monthly taxable earnings × 12**, not on the sum of *all* earnings × 12. The source annualises non-taxable components too.
- **Overtime is excluded from the taxable base annualisation** by the `is_taxable` filter and is gated on `salary.overtime_eligible` — the source pays overtime to everyone.
- **Percentage deductions apply to `gross` after overtime**, which is stated explicitly here because the source's ordering is ambiguous.
- **Negative net pay writes a flagged payslip** rather than silently zeroing with no marker.
- **`basic_salary` override** is honoured through the `is_basic` component, per Story 7.3's rule.
- **Deduction application is idempotent** via `employee_deduction_entries`.
- **Advance settlement is the last step and does not enter any tax or percentage base.**

### 6.4 `PaymentAllocator::allocate(payslip)` — Story 8.2

Splits `net_payable` across the employee's configured channels and freezes the result into `payslip_payment_allocations`.

```
modes = paymentModes(payslip.employee_salary_id) order by display_order
if modes is empty:
    modes = [ {channel: primaryAccountChannel(employee), allocation_type: 'residual',
               employee_bank_account_id: primaryAccount(employee).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 of 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, guaranteed by 7.6
if remaining > 0:
    lines.merge({channel: residual.channel, amount: remaining,
                 bank_account: residual.employee_bank_account_id})

assert sum(lines.amount) == payslip.net_payable                # exact, by construction
replace payslip_payment_allocations for payslip.id with lines
```

- **Percentages apply to `net_payable`, not to gross.** Configuration is entered against gross, but the money that actually exists is the net payable, so that is what is divided. A 10% cheque line on a 30,000 salary pays 1,150 in a month where the net payable came to 11,500.
- **`min(want, remaining)` prevents overdrawing.** A 5,000 fixed cash line in a month where only 3,000 is payable pays 3,000, not 5,000, and the residual line gets nothing.
- **The residual line absorbs rounding.** Percentage lines are rounded individually; whatever is left over goes to the residual channel, so the split always totals `net_payable` to the paisa. This is why exactly one residual line is mandatory.
- **`net_payable = 0` produces no allocation rows and no disbursement items.** The payslip is still generated and viewable.
- **Re-running replaces the rows wholesale**, which is safe while the payslip is `draft`. Once the run is approved the allocations are locked with the payslip.

---

## 7. Story cards

Only the parts that differ from the source document, or that the source omitted, are spelled out. Everything else — CRUD shapes, validation, error codes, DoD — follows Sections 0 and 3.

### Part 0 — Bootstrap

**0.1 Module scaffolding** *(new)*
```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
```
Set the API prefixes in each `RouteServiceProvider` (§0.4). Add `frontend/src/modules/attendance/index.tsx` and `.../payroll/index.tsx`, register both in `registerModules.ts`, add nav groups to `navigation.ts`. Add `config/config.php` per module for tunables.
**AC:** both modules appear in `module:list` as enabled; `/attendance` and `/payroll` render an empty authenticated shell page.

**0.2 Platform prerequisites** *(new)*
Add `companies.timezone` (varchar(64), default `Asia/Dhaka`). Confirm queue worker configuration for `ProcessEmployeeImportJob`-style background jobs — Parts C and H depend on it.
**AC:** an existing company row has a timezone; `php artisan queue:work` processes a test job.

**0.3 Action & permission registry** *(new)*
Seeders `AttendanceModuleSeeder`, `PayrollModuleSeeder` creating the `modules`, `actions`, `module_actions` rows from Section 4, then `PermissionRepositoryInterface::syncForModule()`. Add an `AttendancePayrollRoleSeeder` granting the self-service permissions to the `employee` role.
**AC:** all Section 4 keys exist in `permissions`; a user without a key gets 403 from the corresponding route.

---

### Part A — Configuration

**1.1 Attendance types**
`GET|POST /api/v1/attendance/attendance-types` · `GET|PUT|DELETE /…/{id}` · `PATCH /…/{id}/status`
Permission: `attendance.config-manage` (read also allowed to `attendance.menu-view`).
Rules: `code` unique per company; `system_code` immutable and unique per company where not null; `is_system = true` rows cannot be deleted, only deactivated; a type referenced by any `attendance_records` row cannot be deleted (409).

**1.2 Shifts**
`GET|POST /api/v1/attendance/shifts` · `GET|PUT|DELETE /…/{id}` · `PATCH /…/{id}/status`
Rules: `end_time > start_time` unless `is_overnight = true`; `working_days` non-empty; `min_hours_half_day ≤ min_hours_present ≤ working_hours`; deactivated shifts stay visible in history but cannot be newly assigned; a shift referenced by an active assignment cannot be deleted (409).

**1.3 Policies (leave & holiday)**
`GET /api/v1/attendance/policies?type=leave|holiday` · `POST` · `GET|PUT /…/{id}` · `PATCH /…/{id}/status`
Rules: `code` unique per `(company, policy_type)`; `config` validated against the type-specific schema in §3.1 — a leave policy is rejected if `carry_forward_allowed` is true without `max_carry_forward`; a policy referenced by an assignment, a leave request, or a balance cannot be deleted (409); only `Active` policies are assignable.

**1.4 Assignments** *(cards: 1.4a-BE single assignment · 1.4b-BE bulk assignment · 1.4-FE)*
`GET /api/v1/attendance/assignments` (filters: `scope_type`, `scope_id`, `assignable_type`, `employee_id`, `history=true`)
`POST /api/v1/attendance/assignments` · `POST /…/bulk` · `PATCH /…/{id}` · `POST /…/{id}/end`
Permission: `attendance.assignment-manage`.
Rules: `effective_date` required; `end_date ≥ effective_date`; no overlapping active rows for the same `(scope, assignable_type, assignable_subtype)` — checked under a row lock; bulk assign takes an org-unit filter plus an optional employee multi-select and reports per-row success/failure rather than failing the whole batch; full history is retained (ending an assignment sets `end_date`, never deletes).
**Note:** bulk assignment of more than 200 employees runs as a queued job returning a batch id, mirroring the employee-import pattern.

---

### Part B — Resolution

**2.1 Assignment resolution service** — contract in §6.1.
`GET /api/v1/attendance/resolve/assignment?employee_id=&date=` — permission `attendance.assignment-preview`, HR/admin only.
Returns resolved shift, leave policies, holiday calendar, timezone, and the scope level each was resolved from (so HR can see *why*). Unassigned → 422 with `code: unassigned`.
**AC:** employee-level beats team-level beats department-level; an employee transferred mid-month resolves the old unit for pre-transfer dates; the calculation engine, leave validation, and payroll all return identical context for the same input (integration test).

**2.2 Policy snapshotting** — `attendance_records.policy_snapshot`, contract in §6.2.
`POST /api/v1/attendance/attendance-records/{id}/recalculate` with body `{ "use_current_policy": bool }` — permission `attendance.record-recalculate`, blocked on `is_locked` rows.
**AC:** changing a shift's grace period today does not change a record calculated last month; the override path re-resolves and is audit-logged.

---

### Part C — Runtime

**3.1 Multi-punch check-in/out**
`POST /api/v1/attendance/punch` · `GET /api/v1/attendance/punches?date=`
Rules: two consecutive punches of the same type are rejected (422); `attendance_date` resolved through the shift (overnight shifts attribute the post-midnight punch to the shift's start date); punching on an unassigned date is rejected with `code: unassigned`; server time only — a client-supplied timestamp is ignored except when `source = manual` and the caller holds `attendance.punch-create-others`; every punch emits `PunchCreated`, which queues the day's recalculation.
Rows are append-only; there is no update or delete endpoint.

**3.2 Daily attendance summary** — contract in §6.2.
`GET /api/v1/attendance/attendance-records/today` · `GET /…/attendance-records` (filters: employee, org unit, date range, status) · `GET /…/attendance-records/{id}` · `GET /…/attendance-records/{id}/punches` · `GET /…/attendance-records/export?format=xlsx|csv`
Visibility: `record-view-own` → self only; `record-view-team` → reporting scope via `employee_reporting_managers`; `record-view-all` → whole company.
Export over 5 000 rows runs as a queued job returning a download token, matching the employee-export pattern.

---

### Part D — Approval wiring

**4.1 Register module actions & executors** — full detail in §5.
No new tables. No new UI: the four actions appear in the existing Approval Settings screen (`/admin/approval-settings`) and are configured with the existing workflow builder.
**AC:** all four actions configurable independently; a submitted request appears in `approval_requests` with the prefixed `correlation_id`; with `approval_enabled = 0` the action executes synchronously and no approval row is written; no new approval tables exist in the schema after this story.

---

### Part E — Business layer

**5.1 Leave balances** — tables in §3.1.
`GET /api/v1/attendance/leave-balances?employee_id=&year=` · `GET /…/leave-balances/{employeeId}/history` · `PATCH /…/leave-balances/{id}/adjust`
Adjust requires `attendance.leave-balance-adjust` and a mandatory reason; it writes a `manual_adjustment` ledger row.
Balance rows are created when a leave policy is assigned to an employee (listener on assignment creation) and by the accrual job (Story 9.2).
**AC:** the sum of ledger rows equals the balance columns for any employee/policy/year.

**5.2 Leave application**
`POST /api/v1/attendance/leave-requests` · `GET /…?employee_id=me` · `GET /…/{id}` · `PATCH /…/{id}/cancel`
`total_days` counts only working days — excludes holidays from the resolved calendar and non-working days from the resolved shift; half-day types count 0.5.
Blocks: policy not assigned to the employee; insufficient balance when `lwp_allowed = false`; overlap with an existing pending/approved request; outside `advance_notice_days` / `backdate_limit_days`; missing attachment when `document_required_after_days` is exceeded.
On submit, calls the gateway with `actionSlug: 'leave-approve'` (§5.2).

**5.3 Leave approval** *(cards: 5.3a-BE approval · 5.3b-BE punch-voids-leave · 5.3-FE)*
`GET /api/v1/attendance/leave-requests?status=pending` (HR queue, filterable) · decisions go through the **platform's existing** `approval-requests` approve/reject endpoints — this module adds no approve/reject routes.
`LeaveRequestExecutor` re-validates balance at execution time (it may have changed since submission), deducts the balance with a `consumption` ledger row, and stamps the leave attendance type on **working days only** (same filter as 5.2). Rejection requires a reason and leaves the balance untouched.
Cancelling an already-approved leave reverses the balance with a `reversal` ledger row and recalculates the affected days.

**5.4 Correction request**
`POST /api/v1/attendance/correction-requests` · `GET /…?employee_id=me` · `GET /…/{id}` · `PATCH /…/{id}/cancel`
Blocks: outside the correction window (`attendance.correction_window_days`, default 30, in the module config); a second pending request for the same date; a date whose month is locked, unless the approver later holds `attendance.correction-override-lock`.

**5.5 Correction approval**
HR queue + platform approve/reject endpoints, as 5.3.
`AttendanceCorrectionExecutor`:
```
1. insert new punch rows with source='manual', correction_request_id set
2. stamp superseded_by_id on the punches being replaced
3. recalculate the day (§6.2)
4. mark the request approved
```
Originals are never mutated or deleted — the audit trail must show what the punches were before. Approving against a locked month requires `attendance.correction-override-lock` (403 otherwise) and is audit-logged prominently.

---

### Part F — Monthly closing

**6.1 Monthly attendance approval**
`GET /api/v1/attendance/monthly-attendance?month=&year=` · `GET /…/{id}` · `POST /…/build?month=&year=` (aggregate/refresh) · `POST /…/{id}/approve` *(via approval gateway)* · `POST /…/bulk-approve` · `POST /…/{id}/reject` · `POST /…/{id}/unlock`
`unresolved_flag` is true when a pending correction or leave exists in the month, or any day is `missing_check_out`, or any day is unassigned. Approving an unresolved month requires an explicit override flag in the request body plus `attendance.monthly-approve`; it is audit-logged.
`MonthlyAttendanceExecutor` sets `status=approved`, `is_locked=true` on the month's `attendance_records`, and `ready_for_payroll=true`.
Unlock requires `attendance.monthly-unlock`, a mandatory reason, and is blocked once the month is frozen.

**6.2 Explicit freeze state**
`POST /api/v1/payroll/monthly-attendance/{id}/freeze` · `POST /…/unfreeze`
Lives in the **Payroll** module because `payroll.month-freeze` is a Finance responsibility.
Rules: freeze requires `status = approved` (409 otherwise); freeze publishes `MonthFrozen`; unfreeze requires a reason (422 otherwise); unfreezing a month whose run is already `paid` requires `payroll.month-unfreeze-paid` (403 otherwise).
Frontend shows four distinct states: Pending / Approved / Frozen / Paid.
**AC:** a role holding `attendance.monthly-approve` but not `payroll.month-freeze` cannot see or invoke Freeze.

---

### Part G — Payroll configuration

**7.0 Payroll settings** *(new)* — table in §3.2.
`GET|PUT /api/v1/payroll/settings` — permission `payroll.settings-manage`. Auto-created with defaults on first read.

**7.1 Salary structures** — existing table, no migration.
`GET|POST /api/v1/payroll/salary-structures` · `GET|PUT /…/{id}` · `PATCH /…/{id}/activate|deactivate`
**Ownership decision:** the Payroll module owns write access. The existing read-only endpoints under `/api/v1/configuration/salary-structures` stay as-is for pickers. Do not add a second write path.
Rules: `code` unique per company; a `requires_basic` structure cannot be activated without exactly one `is_basic` component; deactivating blocks new `employee_salaries` assignments but leaves existing ones intact.

**7.2 Salary structure components** — new table, §3.2.
`GET|POST /api/v1/payroll/salary-structures/{id}/components` · `PUT|DELETE /api/v1/payroll/salary-structure-components/{id}` · `PATCH /…/reorder`
Rules: at most one `is_basic` per structure; `percentage_base` required when `calculation_type = percentage`; `value > 0`; deleting the sole `is_basic` component of a `requires_basic` structure → 409; percentage earnings summing over 100% of the same base → soft warning in the response, not a block.
Frontend: drag-to-reorder builder with a live sample-payslip preview.

**7.3 Tax slabs**
`GET|POST /api/v1/payroll/tax-slabs` · `GET|PUT /…/{id}` · `PATCH /…/{id}/status`
Rules: brackets must be contiguous and non-overlapping, ascending, with exactly one open-ended top bracket (`max_income: null`) — the source's `calculateSlabTax` silently under-taxes if a gap exists; one Active set per `(company, effective_year)`; a set referenced by any payslip cannot be deleted or edited (create a new set instead).

**7.4 Employee deductions & loans** — tables in §3.2.
`GET|POST /api/v1/payroll/employee-deductions` · `PATCH /…/{id}` · `POST /…/{id}/cancel`
Installment application is idempotent per run (§3.2); cancelling stops future installments without touching generated payslips; if applying an installment would push net pay below zero, it is skipped and the payslip is flagged `needs_review`.

**7.5 Salary advances** *(new)* — table in §3.2, settlement contract in §6.3.
`GET|POST /api/v1/payroll/salary-advances` · `GET|PUT /…/{id}` · `POST /…/{id}/submit` · `POST /…/{id}/record-payment` · `POST /…/{id}/cancel` · `GET /api/v1/payroll/salary-advances/summary?month=&year=`
Permissions: `payroll.advance-manage` for everything except approval, which goes through the platform gateway on `payroll.advance-approve`.

Rules:
- Every endpoint 409s when `payroll_settings.advance_enabled = false`. The nav item is hidden too.
- `amount` is resolved server-side at creation: `fixed → requested_value`, `percentage → round2(basis_gross * requested_value / 100)`. `basis_gross` is copied from the active `employee_salaries` row and never recomputed afterwards.
- Ceiling check on **the month's total**, not on the single request: `sum(amount of non-cancelled, non-rejected advances for employee/month/year) + this.amount` must satisfy both `advance_max_percentage` of `basis_gross` and `advance_max_amount`. Multiple advances in one month are allowed; the total is what is capped.
- `advance_requires_approval = false` → `submit` approves immediately and records `approved_by`; otherwise it submits through the gateway with `correlationId = "salary_advance:{id}"`.
- `record-payment` requires status `approved`, requires `payment_channel`, and requires `payment_reference` when the channel is `cheque` or `bank`. It moves the row to `paid`. **This is a record of a handover HR performed outside the system** — nothing is transmitted anywhere.
- An advance whose month is already covered by an `approved` or later payroll run cannot be created, edited, or cancelled — 409 with the run id. 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.

**AC:** an employee on 30,000 with a 50% advance gets a 15,000 row; a second 10% request the same month is rejected as over the 50% ceiling; after `record-payment`, that month's payslip shows `net_payable = net_pay − 15,000`; the run's approval flips the advance to `settled`.

Frontend: list with month/status/employee filters and a running "advanced this month" total per employee; a request drawer that shows the resolved amount live as fixed/percentage is toggled, alongside the remaining headroom under the ceiling; a record-payment dialog capturing channel and reference; the employee's payslip view showing an "Advance already paid" line between net pay and net payable.

**7.6 Payment mode split** *(new)* — table in §3.2, allocation contract in §6.4.
`GET|PUT /api/v1/payroll/employee-salaries/{employeeSalaryId}/payment-modes` — permission `payroll.payment-mode-manage`.
The `PUT` replaces the whole set in one transaction; there is no per-row endpoint, because the rows are only valid as a set (exactly one residual, totals bounded).

**Ownership decision:** the table, the API, and the allocation logic belong to the **Payroll module** — payout channels are a payroll concern and the Employee module must not learn about them. The *UI* is a Payroll-module component mounted inside the Employee module's salary assignment form, so HR configures the split at the same moment they set the salary, in one screen. This mirrors 7.1: Payroll owns the write path, Employee hosts the surface.

Rules: exactly one `residual` row; `value` required and `> 0` for `fixed` and `percentage`, null for `residual`; percentages in `(0, 100]`; `employee_bank_account_id` required for `bank`/`mobile_banking` and must belong to that employee; at most one row per channel; fixed + percentage totalling more than `gross_salary` returns a soft warning rather than a block, since the real net is lower anyway; an empty set is valid and means "everything to the primary account".

**AC:** configuring cash 5,000 fixed · cheque 10% · bank residual on a 30,000 salary, with net payable 11,500, produces allocations of 5,000 / 1,150 / 5,350 that sum exactly; saving a set with two residual rows returns 422; saving a bank row without an account returns 422.

Frontend: a repeatable-row builder inside the salary form — channel, type, value, bank account — with a live preview that runs the sample net payable through the allocator, and a permanent hint that the last row is the residual.

**Employee salary, tax profile, bank accounts — no new stories.**
These already exist in the Employee module and are 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 /…/{id}/set-primary` |

The only additions required, tracked as small tasks on the Employee module rather than new stories:
- `employee_salaries.currency_code` and `payment_frequency` validated against `config('employee.salaries')` — already listed there.
- Bank account validation: at least one of (bank_name + account_number) or (mobile_banking_provider + mobile_banking_number) must be present.
- A "no primary payment method" report endpoint for pre-disbursement checks — scoped to employees who actually have a `bank`/`mobile_banking` allocation, per 8.3.

---

### Part H — Payroll execution

**8.1 Payroll run creation**
`GET|POST /api/v1/payroll/payroll-runs` · `GET /…/{id}`
Rules: one run per `(company, month, year, run_type)`; only employees whose month is `ready_for_payroll` are included; if any active employee is not ready, creation fails listing them, unless the caller holds `payroll.run-override-readiness`; status flow `draft → processing → pending_approval → approved → paid → locked` (with `failed` reachable from `processing`).

**8.0 Attendance snapshot** *(built after 8.1)*
`POST /api/v1/payroll/payroll-runs/{id}/build-snapshots` · `GET /api/v1/payroll/attendance-snapshots?payroll_run_id=`
Rules: source month must be **frozen** (409 otherwise); one snapshot per employee per run; immutable — an off-cycle run creates new rows, never updates old ones; the payslip generator reads only from here.
Frontend adds a divergence indicator flagging where live attendance has since drifted from the snapshot (diagnostic only, read-only).

**8.2 Payslip generation** — contract in §6.3 and §6.4. *(cards: 8.2a-BE pipeline · 8.2b-BE advance settlement + allocation · 8.2-FE)*
`POST /api/v1/payroll/payroll-runs/{id}/generate-payslips` (queued; returns a batch id and progress endpoint) · `GET /api/v1/payroll/payroll-runs/{id}/payslips` · `GET /api/v1/payroll/payslips/{id}` · `POST /api/v1/payroll/payslips/{id}/regenerate` · `GET /api/v1/payroll/payslips/{id}/download`
Generation is chunked and resumable — a 500-employee run must not be one HTTP request. Regeneration is allowed only on `draft` payslips and reverses the run's deduction entries first. `download` renders a PDF; `payslip-view-own` restricts to self.
Generation also settles salary advances (§6.3) and freezes the payment split into `payslip_payment_allocations` via `PaymentAllocator` (§6.4). The payslip and its PDF show `net_pay`, `advance_paid`, and `net_payable` as three separate lines, plus the channel breakdown. The run's `total_amount` is the sum of **`net_payable`**, not `net_pay` — it is the money the company still has to move.

**8.3 Payroll approval & disbursement** *(cards: 8.3a-BE approval + settlement · 8.3b-BE disbursement · 8.3a-FE · 8.3b-FE)*
Approval goes through the platform gateway (`payroll.run-approve`). `PayrollRunExecutor` sets the run to `approved` and its payslips to `finalized`, locking both. It also settles the netted salary advances (`paid → settled`) and raises an `employee_deductions` row for any `advance_carry_forward`, per §6.3.
`POST /api/v1/payroll/payroll-runs/{id}/disburse` · `GET /api/v1/payroll/disbursement-batches` · `GET /…/{id}` · `POST /…/{id}/retry` · `POST /…/{id}/confirm` · `POST /api/v1/payroll/disbursement-batch-items/{id}/acknowledge`
Rules: disbursement requires the run to be `approved`; batches are built from `payslip_payment_allocations`, **one batch per distinct channel present in the run** (so a company paying part cash and part bank gets two batches from one run); each allocation produces exactly one item, enforced by `unique(payslip_payment_allocation_id)`; batch total must equal the sum of its items; retry re-sends only `failed` items; a payslip becomes `paid` only when every item across all of its batches is `confirmed`; when all items of all batches are confirmed the run becomes `paid`.

**Readiness check, corrected for multi-channel:** an employee is blocked only when they have a `bank` or `mobile_banking` allocation with `amount > 0` 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.

`cash` and `cheque` batches are registers: `retry` is not applicable, and items move to `confirmed` through `acknowledge` (which stamps `acknowledged_by`/`acknowledged_at`, and `payment_reference` for cheque numbers) rather than through a transmission callback.

Frontend: the disburse screen groups by channel with a per-channel total, prints a signable cash disbursement register and a cheque schedule, and offers row-by-row acknowledgement with a "confirm all" for a fully reconciled register.

---

### Part J — Automation

**9.1 Nightly attendance close** *(new)*
Scheduled job, per company, at company-local 02:00. For the previous day, for every active employee without an `attendance_records` row, runs `calculateDaily` — this is what actually creates Absent / Holiday / Weekend rows. Unassigned dates are skipped and reported, not defaulted. Idempotent; safe to re-run; skips locked records.
**AC:** an employee who never punched on a working day has an `absent` record by the next morning; re-running the job produces no changes.

**9.2 Leave accrual & carry-forward** *(new)*
- Monthly accrual job for policies with `accrual_method = monthly`: `entitlement_days / 12` added, with an `accrual` ledger row, idempotent per `(employee, policy, year, month)`.
- Year-end carry-forward job: `carry = min(available, max_carry_forward)` into the next year's balance with a `carry_forward` ledger row.
- Manual trigger endpoints for both, behind `attendance.leave-balance-adjust`, for backfilling.

**9.3 Seeders** *(new)*
`AttendanceTypeSeeder` — the eleven system types with their `system_code` and `is_system = true`, per company.
`ShiftSeeder` — one default 09:00–18:00 shift, Sun–Thu.
Both idempotent (`updateOrCreate`).

**9.4 Job monitoring UI** *(new — 28 July 2026)*
`/attendance/jobs` — permissions `attendance.record-recalculate` and `attendance.leave-balance-adjust`. **No new endpoints**; it is the missing surface for the manual triggers 9.1 and 9.2 already ship.

Stories 9.1 and 9.2 expose `POST /attendance/jobs/close-day`, `POST /attendance/jobs/accrue-leave`, and `POST /attendance/jobs/carry-forward` behind HR permissions, and 9.1 requires that employees skipped for having no resolved shift are "surfaced in a report HR can read, not only in logs". No screen was ever specified for either. Without one, a backfill needs a developer and a silently skipped employee stays invisible until the month cannot be closed.

The screen shows last-run time and outcome per scheduled job in company-local time, the **unassigned-employee report** with each row one click from the Assignments screen, and manual-run controls within the permitted ranges (nightly close at most 90 days back; accrual within ±5 years). Re-running an already-applied accrual reports "0 applied" as a success, not an error.

---

## 8. Story mapping

| Source card | This spec |
|---|---|
| 1.1 – 1.4 | unchanged IDs, corrected endpoints & schema |
| 2.1, 2.2 | unchanged |
| 3.1, 3.2 | unchanged; calculation order corrected |
| 4.1 | rewritten (§5) |
| 5.1 – 5.5 | unchanged IDs; approve/reject routes removed in favour of platform endpoints |
| 6.1, 6.2 | unchanged; 6.2 moved to the Payroll module |
| 6.3 | **renumbered 8.0**, moved after 8.1 |
| 7.1, 7.2 | unchanged |
| 7.3, 7.4, 7.7 | **dropped** — already exist in the Employee module |
| 7.5 | **renumbered 7.3** (tax slabs) |
| 7.6 | **renumbered 7.4** (deductions & loans) |
| 8.1 – 8.3 | unchanged |
| — | **new:** 0.1, 0.2, 0.3, 7.0, 7.5, 7.6, 9.1, 9.2, 9.3, 9.4 |

Net: 27 source stories → 24 carried over + 10 new = **34 stories**. Three were dropped as duplicates of shipped work; three of the new ones are infrastructure the source assumed but never specified, two (7.5, 7.6) are the salary-advance and payment-split requirements added on 28 July 2026, and one (9.4) is the job-monitoring screen a menu audit found missing the same day.

**Where each story appears in the UI** is recorded per card in the task-cards document (`**Menu:**`) and rolled up in its "Menu traceability" appendix. Two placements are deliberate and look wrong at a glance: **6.2 Monthly Freeze sits in the Payroll menu**, not beside 6.1 Monthly Approval in Attendance, because freezing is a Finance responsibility and no seeded role holds both permissions (§4, §6.2); and **7.6 Payment Mode Split is a Payroll-owned panel hosted inside the Employee module's salary form**, because HR sets the split at the moment they set the salary (Story 7.6).

---

## 9. Open decisions

Four decisions are now resolved and recorded here; four remain open.

### Resolved

**R1 — Attendance config placement.** *Decided 27 July 2026.* `attendance_types`, `shifts`, and `policies` live in the **Attendance module** under `/api/v1/attendance/…`, surfaced through a **single "Configuration" submenu** in the Attendance nav group:

```
Attendance
  Configuration        ← attendance types · shifts · policies · assignments
  Punches
  Attendance Records
  Corrections
  Leave
  Monthly Approval
```

Frontend routes stay `/attendance/config/*`. The Configuration module is not involved.

**R2 — Punch voids leave.** *Decided 27 July 2026.* 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 to the balance. Encoded in §6.2 and requiring the `leave_request_days` table (§3.1).

Consequences, all covered by the affected stories:
- Voiding is per **date**, not per request. Days 1, 2, 4, and 5 of a five-day leave remain consumed when the employee punches on day 3.
- The refund is a `reversal` ledger row referencing the voided day, so the balance stays reconcilable.
- `leave_requests.total_days` is not mutated; the effective consumed figure is `sum(day_value)` over `active` rows. The request keeps its original shape for audit.
- The monthly aggregate counts a voided day under its calculated status, not under leave.
- **Open sub-question:** the half-day carve-out in §6.2. A half-day leave is voided only when the punches show a full day's work. If the intent is that *any* punch voids even a half-day leave, say so — it removes one condition from the pseudocode and makes half-day leave effectively unusable.

**R3 — Salary advances are payments, not deductions.** *Decided 28 July 2026.* Some companies pay part of a month's salary early when the payroll itself runs late. The amount may be a fixed sum or a percentage of gross, configured per company on `payroll_settings`.

- Gross, percentage components, income tax, and loan installments are **all computed on the full gross**, exactly as if no advance existed. The advance is subtracted at the very end, from the net. Netting it earlier would tax the employee on 15,000 when they earned 30,000.
- `net_pay` keeps its meaning — earned net. The new `net_payable` is what gets disbursed. Reports and tax certificates read `net_pay`.
- **HR pays the advance by hand and records it in the system** — cash, cheque, or a manual transfer, captured as `payment_channel` + `payment_reference`. There is no advance disbursement batch and no automated payment path. Only advances actually recorded as `paid` are netted off.
- `salary_advances` is a separate table from `employee_deductions.type = 'advance'`. The latter stays what it always was: a multi-month recoverable loan.
- Over-advancing (advance exceeds the earned net after unpaid absence) is not an error. `net_payable` floors at 0 and the excess becomes an `employee_deductions` advance row recoverable next month, with the payslip flagged `needs_review`.

Encoded in §3.2 (`salary_advances`, `payroll_settings` columns, `payslips` columns), §6.3, and Story 7.5.

**R4 — Salary is paid across cash, cheque, and bank in a configured split.** *Decided 28 July 2026.* The split is set when the salary is assigned, stored per `employee_salaries` revision, and frozen onto each payslip at generation.

- Split lines are `fixed`, `percentage`, or `residual`, and **exactly one residual line is mandatory**. That is what guarantees the split totals the net payable exactly regardless of deductions, advances, or rounding — a pure fixed/percentage configuration can never do that against a net that changes every month.
- Percentages divide `net_payable`, not gross. Configuration is entered against gross because that is what HR knows, but only the payable money can actually be split.
- `disbursement_batches.payout_channel` widens to include `cash` and `cheque`, and `disbursement_batch_items.employee_bank_account_id` becomes nullable. Cash and cheque batches are auditable registers, confirmed by acknowledgement rather than transmission.
- The pre-disbursement readiness rule changes: a bank account is required only from employees who actually have a bank or mobile-banking allocation.

Encoded in §3.2 (`employee_salary_payment_modes`, `payslip_payment_allocations`, widened disbursement tables), §6.4, and Stories 7.6 and 8.3.

### Still open
2. **Off-cycle payroll runs** — the schema supports `run_type` but no story covers the workflow (which employees, which period, how it interacts with an already-paid regular run). *Recommendation: defer to a follow-up phase; keep the column.*
3. **Notifications** — six acceptance criteria say "the system shall notify the employee". No notification infrastructure exists (`docs/NOTIFICATION_SYSTEM_BLUEPRINT.md` is a design only). *Recommendation: emit domain events now (`LeaveDecided`, `CorrectionDecided`, `PayslipPublished`) with no listeners; wire notifications when the blueprint is built.* Remove the notification wording from acceptance criteria until then.
4. **Biometric ingestion** — `source = biometric` exists with no ingestion path. *Recommendation: a separate integration story with device auth, batch upload, and de-duplication; out of scope here.*
5. **Employee self-service scope** — does an employee who is also a manager see their team's records through `record-view-team`, and is that scope reporting-manager-based or department-based? This spec assumes reporting-manager-based via `employee_reporting_managers`. Confirm with HR.
6. **Half-day carve-out under R2** — see the sub-question in R2 above. Blocks nothing; it is a one-condition change in §6.2 if the ruling differs.

---

## 10. Events

Emitted by this module; listeners noted where they exist.

| Event | Emitted by | Consumed by |
|---|---|---|
| `PunchCreated` | 3.1 | queued daily recalculation |
| `AttendanceCalculated` | 3.2 | monthly aggregate refresh |
| `AssignmentChanged` | 1.4 | resolution cache invalidation |
| `HolidayPolicyUpdated` | 1.3 | resolution cache invalidation |
| `EmployeeTransferred` | Employee module (`organization-assignments/transfer`) | resolution cache invalidation — **requires adding the event to the Employee module** |
| `LeaveDecided` | 5.3 | balance ledger, attendance stamping, (future) notification |
| `LeaveDayVoided` | 3.2 | balance refund ledger, (future) notification to employee and approver |
| `CorrectionDecided` | 5.5 | day recalculation, (future) notification |
| `MonthApproved` | 6.1 | record locking |
| `MonthFrozen` | 6.2 | snapshot build eligibility |
| `PayrollRunApproved` | 8.3 | payslip finalisation, advance settlement |
| `PayslipPublished` | 8.3 | (future) notification |
| `SalaryAdvanceApproved` | 7.5 | (future) notification to employee and HR |
| `SalaryAdvancePaid` | 7.5 | eligible for netting on the next payslip, (future) notification |
| `SalaryAdvanceSettled` | 8.3 | advance ledger close-out, (future) notification |
| `PaymentAllocationsFrozen` | 8.2 | disbursement batch eligibility |

`EmployeeTransferred` does not currently exist — add it in `EmployeeOrganizationAssignmentService::transfer()` as part of Story 2.1.
