# Deduction Policy — Technical Design

**Status:** Design only (no implementation yet)  
**Based on:** Existing Payroll deduction ledger + Attendance policy authoring patterns  
**Date:** 2026-10-07  
**Module owner:** Payroll (policy authoring + obligation + payslip apply)  
**Upstream signals:** Attendance (late/absence metrics), manual HR, future domain sources  

---

## 0. Purpose

Introduce a **generic Deduction Policy** so the company can configure *when* and *how* deductions are generated, while reusing the existing **employee deduction obligation + payslip apply** path.

This design is **not** limited to attendance fines. Sources may include late/early/absence, disciplinary fines, damage recovery, advance/loan recovery rules, and future business-specific sources.

---

## 1. Goals / Non-goals

### Goals

1. Separate **Policy** (rules) from **Obligation/Entry** (money owed) from **Application** (payslip installment).
2. Align authoring UX/lifecycle with `attendance_policies` (typed JSON `config`, `effective_date`, status).
3. Keep existing manual `employee_deductions` CRUD and payslip `applyInstallment` behavior.
4. Support first concrete source: **attendance late threshold → day-based or fixed deduction**.
5. Allow new sources later via strategy + config schema, without rewriting the ledger.

### Non-goals (v1)

1. Replacing `salary_structure_components` statutory/template deductions.
2. Replacing income tax slab engine.
3. Putting money math inside Attendance daily calc.
4. Full Policy Group / org assignment UI (phase 2).
5. Approval workflow on every auto-generated obligation (phase 2 optional).
6. Soft-delete rewrite of existing deduction tables.

---

## 2. Design principles

| Principle | Decision |
|---|---|
| Maximum reuse | Keep `employee_deductions` + `employee_deduction_entries` + `PayslipCalculatorService` apply loop |
| Pattern clone | Author policies like `AttendancePolicy` (not like Shift columns, not like installment ledger) |
| Module boundary | Policy + obligation live in **Payroll**; Attendance only exposes **signals** |
| Extend, don't break | Existing APIs/enums keep working; new columns nullable |
| Idempotent generation | Same (policy, employee, period) must not double-create obligations |
| Explicit money base | Day-based amount must declare rate basis in config (no silent assumption) |

---

## 3. Domain model

### 3.1 Three layers

```text
┌─────────────────────────────────────────────────────────────┐
│  DeductionPolicy                                            │
│  "When / how should a deduction be generated?"              │
│  Table: deduction_policies                                  │
└───────────────────────────┬─────────────────────────────────┘
                            │ evaluate (job / event)
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  EmployeeDeduction (Obligation)                             │
│  "This employee owes this amount (balance / installment)" │
│  Table: employee_deductions (EXISTING, extended)            │
│  source = manual | policy | system                          │
└───────────────────────────┬─────────────────────────────────┘
                            │ payslip generate
                            ▼
┌─────────────────────────────────────────────────────────────┐
│  EmployeeDeductionEntry (Application)                       │
│  "This run applied X to payslip Y"                          │
│  Table: employee_deduction_entries (EXISTING)               │
└─────────────────────────────────────────────────────────────┘
```

### 3.2 Why not put policy on `attendance_policies`

| Option | Pros | Cons | Decision |
|---|---|---|---|
| `attendance_policies.policy_type=deduction` | Reuse one table | Pulls money domain into Attendance; permissions/menu mismatch; leave/holiday consumers must ignore new type | **Reject for v1** |
| Payroll `deduction_policies` | Clear ownership; same *shape* as AttendancePolicy | Slight duplication of table shape | **Accept** |

### 3.3 Relationship to existing deduction tracks

| Existing track | Role after this design |
|---|---|
| Structure components | Unchanged — template/statutory lines on salary structure |
| Manual employee deductions | Unchanged CRUD; `source=manual` |
| Advance carry-forward | Unchanged; `source=system` |
| Income tax | Unchanged |
| **New** policy evaluator | Creates/updates obligations with `source=policy` |

---

## 4. Database design

### 4.1 New table: `deduction_policies`

Shape mirrors `attendance_policies`.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `company_id` | FK → companies | cascade, `BelongsToCompany` |
| `source_type` | string(40) | e.g. `attendance_late`, `attendance_absence`, `fine`, `damage`, `loan_recovery`, `other` |
| `name` | string(150) | |
| `code` | string(50) | unique per `(company_id, source_type, code)` |
| `effective_date` | date | policy becomes eligible from this date |
| `config` | json | source-specific schema (see §5) |
| `status` | string(20) | `Active` / `Inactive` / `Archived` (Title Case, like Attendance) |
| `created_by` / `updated_by` | nullable FK users | |
| `timestamps` | | |

Indexes:
- unique `(company_id, source_type, code)`
- index `(company_id, source_type, status)`

**No** soft deletes in v1 (match current Payroll deduction models).

### 4.2 Extend: `employee_deductions`

Additive nullable columns only (non-breaking):

| Column | Type | Notes |
|---|---|---|
| `source` | string(20) null | `manual` (default for old rows via app), `policy`, `system` |
| `deduction_policy_id` | nullable FK → `deduction_policies` | nullOnDelete (keep obligation if policy archived/deleted blocked) |
| `origin_period_year` | smallint null | evaluation period |
| `origin_period_month` | tinyint null | evaluation period |
| `origin_ref` | string(100) null | free-form idempotency / case id (e.g. `att-late:2026-09`, damage case id) |
| `calculation_snapshot` | json null | inputs used at generation (counts, rates, formula result) for audit |

New unique (partial or app-enforced) for policy-generated rows:

```text
unique (company_id, employee_id, deduction_policy_id, origin_period_year, origin_period_month)
  where source = 'policy' and deduction_policy_id is not null
```

If DB partial unique is awkward on MySQL version, enforce in service with repository lookup (same pattern as advance carry-forward remarks).

### 4.3 Unchanged tables

- `employee_deduction_entries` — apply ledger as-is  
- `salary_structure_components` — out of scope  
- `payslips.deductions_breakdown` — continues to merge by type/code  
- `attendance_records` / snapshots — read-only inputs  

### 4.4 Optional later (phase 2)

- `deduction_policy_assignments` or reuse Attendance `assignments` with `assignable_subtype=deduction`  
- `deduction_policy_evaluations` audit log table (if snapshot on obligation is insufficient)  

### 4.5 Migration impact

1. Create `deduction_policies`.  
2. Alter `employee_deductions` add nullable columns.  
3. Backfill `source = 'manual'` for existing rows (data migration).  
4. No change to payslip generation math.  

**Breaking change:** none for existing APIs if new fields optional and defaults applied.

---

## 5. Policy `config` schemas

Validation: dedicated `ValidationRule` classes per `source_type` (clone of `LeavePolicyConfigRule` / `HolidayPolicyConfigRule`). Unknown keys rejected.

### 5.1 Common config envelope (all sources)

```json
{
  "calculation_method": "fixed_amount | percentage | day_based",
  "fixed_amount": 500,
  "percentage": 10,
  "percentage_base": "gross | basic | net_preview",
  "day_count": 1,
  "day_rate_base": "basic | gross",
  "day_rate_divisor": "month_working_days | calendar_days | fixed_30",
  "installment_mode": "full | equal_split",
  "installment_count": 1,
  "deduction_type": "fine | other | loan | advance",
  "priority": 100,
  "stacking": "allow | skip_if_exists",
  "notes": "optional"
}
```

**v1 required subset depends on `calculation_method`:**
- `fixed_amount` → require `fixed_amount > 0`
- `percentage` → require `percentage`, `percentage_base`
- `day_based` → require `day_count > 0`, `day_rate_base`, `day_rate_divisor`

`deduction_type` maps into existing `employee_deductions.type` enum (compat with payslip breakdown keys).

### 5.2 Source: `attendance_late`

```json
{
  "trigger": {
    "metric": "late_count",
    "operator": "gte",
    "threshold": 3,
    "period": "calendar_month"
  },
  "calculation_method": "day_based",
  "day_count": 1,
  "day_rate_base": "basic",
  "day_rate_divisor": "month_working_days",
  "installment_mode": "full",
  "installment_count": 1,
  "deduction_type": "fine",
  "stacking": "skip_if_exists"
}
```

**Metric definitions (v1):**
- `late_count` — from monthly attendance aggregation / snapshot (`late_count`), same definition Attendance already uses for payslip display.
- Future: `late_minutes_sum`, `early_leave_count` (schema-ready, evaluator later).

**Operators (v1):** `gte`, `gt`, `eq`  
**Period (v1):** `calendar_month` only (payroll month alignment).

### 5.3 Source: `attendance_absence` (phase 1.5 / 2)

```json
{
  "trigger": {
    "metric": "absent_days",
    "operator": "gte",
    "threshold": 1,
    "period": "calendar_month",
    "exclude_unpaid_proration": true
  },
  "calculation_method": "day_based",
  "day_count": 1,
  "day_rate_base": "basic",
  "day_rate_divisor": "month_working_days",
  "deduction_type": "fine"
}
```

Note: unpaid absence already reduces earnings via proration. Policy must set `exclude_unpaid_proration` carefully to avoid double penalty — default **true** (policy fine only for *paid* absence edge cases) or document that absence policy is additive intentional.

### 5.4 Source: `fine` / `damage` / `other` (manual-trigger policy)

These policies define **calc template**; HR/admin supplies `subject` (employee + optional case amount) when evaluating manually:

```json
{
  "calculation_method": "fixed_amount",
  "fixed_amount": 1000,
  "allow_override_amount": true,
  "installment_mode": "equal_split",
  "installment_count": 3,
  "deduction_type": "fine"
}
```

API: `POST /deduction-policies/{id}/evaluate` with `{ employee_id, amount?, period? }`.

### 5.5 Source: `loan_recovery` (phase 2)

May wrap existing loan create UX with policy defaults (caps, max installment %). Not required for v1.

---

## 6. Calculation formulas

### 6.1 Obligation amount

```text
if calculation_method == fixed_amount:
  total = fixed_amount   # or override if allowed

if calculation_method == percentage:
  base = employee.gross_salary | basic_salary | (optional net_preview — v2)
  total = base * percentage / 100

if calculation_method == day_based:
  base = employee.basic_salary | gross_salary
  divisor = month_working_days | 30 | calendar_days_in_month
  daily_rate = base / divisor
  total = daily_rate * day_count
```

Round with existing Payroll `Money::round2` / component rounding rules.

### 6.2 Installments

```text
if installment_mode == full:
  installment_amount = total
else: # equal_split
  installment_amount = round2(total / installment_count)
  # last installment absorbs remainder on final month (document algorithm)
```

Create one `employee_deductions` row with `total_amount`, `remaining_balance=total`, `installment_amount`, `start_month/year` = evaluation period (or next payroll month — see §7.3).

### 6.3 Trigger evaluation (`attendance_late`)

```text
for each Active policy where source_type=attendance_late
  and effective_date <= period_end:
    for each employee in company (or assignment scope later):
      metric = snapshot.late_count (or recompute from records)
      if metric <op> threshold:
        create obligation unless stacking=skip_if_exists and already exists
```

---

## 7. Runtime flows

### 7.1 Policy CRUD (authoring)

```text
FormRequest + DeductionPolicyConfigRule(source_type)
  → DeductionPolicyController
  → DeductionPolicyService (transaction, uniqueness, status rules)
  → DeductionPolicyRepository
  → DeductionPolicy model
```

Rules (clone Attendance):
- `source_type` immutable after create  
- Unique `(company, source_type, code)` and name uniqueness per source  
- Delete blocked if referenced by `employee_deductions.deduction_policy_id`  
- Status: `Active` / `Inactive` / `Archived`; list hides Archived by default  

### 7.2 Manual obligation (existing)

```text
POST /employee-deductions
  → source = manual
  → deduction_policy_id = null
  → existing validation + applyInstallment path
```

### 7.3 Automatic evaluation (attendance_late)

**Recommended trigger (v1):** after monthly attendance freeze for `(company, month, year)` **or** dedicated Payroll job `payroll:evaluate-deduction-policies {month} {year}` runnable from Jobs UI.

Rationale: freeze means attendance numbers are stable; avoids regenerating from draft punches.

```text
MonthlyAttendanceFreeze completed (or artisan job)
  → DeductionPolicyEvaluationService::evaluateForPeriod(companyId, year, month)
      → load Active attendance_late policies
      → load attendance snapshots / aggregates for period
      → for matches: EmployeeDeductionService::createFromPolicy(...)
```

**Start month:** default `start_month/year = evaluation period` so the **same month's** payslip can recover (if freeze precedes payslip generate). Document ops order:

1. Freeze monthly attendance  
2. Evaluate deduction policies  
3. Create/generate payroll run payslips  

If freeze is after payslip draft, regenerate payslips (existing reverse+apply supports this).

### 7.4 Payslip apply (unchanged path)

```text
PayslipCalculatorService::generateForEmployee
  → structure deductions
  → income_tax
  → getActiveDeductionsForEmployee
  → applyInstallment (idempotent)
  → PayslipFinaliser
```

Policy-generated rows look like any other active deduction to the calculator.

### 7.5 Idempotency & regeneration

| Event | Behavior |
|---|---|
| Re-run evaluator same period | Skip if obligation exists (`skip_if_exists`) or update amount only if policy says `replace` (v2; v1 = skip) |
| Payslip regenerate | Existing `reverseInstallmentsForPayslip` |
| Policy inactivated mid-period | Existing obligations remain; evaluator stops creating new |
| Policy archived | Cannot delete if obligations reference; archive only |

---

## 8. Module / class structure (Payroll)

```text
Modules/Payroll/
  app/Models/DeductionPolicy.php
  app/Enums/DeductionPolicyStatus.php          # or string constants like Attendance
  app/Enums/DeductionPolicySourceType.php
  app/Enums/DeductionObligationSource.php      # manual|policy|system
  app/Rules/AttendanceLateDeductionConfigRule.php
  app/Rules/FineDeductionConfigRule.php
  app/Rules/DamageDeductionConfigRule.php
  app/Http/Controllers/DeductionPolicyController.php
  app/Http/Requests/StoreDeductionPolicyRequest.php
  app/Http/Requests/UpdateDeductionPolicyRequest.php
  app/Http/Requests/UpdateDeductionPolicyStatusRequest.php
  app/Http/Requests/IndexDeductionPolicyRequest.php
  app/Http/Requests/EvaluateDeductionPolicyRequest.php
  app/Http/Resources/DeductionPolicyResource.php
  app/Services/Contracts/DeductionPolicyServiceInterface.php
  app/Services/DeductionPolicyService.php
  app/Services/Contracts/DeductionPolicyEvaluationServiceInterface.php
  app/Services/DeductionPolicyEvaluationService.php
  app/Services/Evaluators/DeductionPolicyEvaluatorInterface.php
  app/Services/Evaluators/AttendanceLateDeductionEvaluator.php
  app/Services/Evaluators/ManualTemplateDeductionEvaluator.php
  app/Repositories/Contracts/DeductionPolicyRepositoryInterface.php
  app/Repositories/DeductionPolicyRepository.php
  database/migrations/xxxx_create_deduction_policies_table.php
  database/migrations/xxxx_add_policy_fields_to_employee_deductions_table.php
```

Extend existing:
- `EmployeeDeduction` model fillable/casts  
- `EmployeeDeductionService::createFromPolicy(...)`  
- `EmployeeDeductionResource` expose new fields  
- `PayrollServiceProvider` bindings  
- `routes/api.php`  
- `config/actions.php` permissions  

**Do not** put evaluators in Attendance module; Attendance may later emit a domain event Payroll listens to.

---

## 9. API design

Base: `/api/v1/payroll` (existing prefix).

### 9.1 Policies

| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | `/deduction-policies` | `payroll.deduction-policy-view` | require `source_type` filter optional |
| GET | `/deduction-policies/{id}` | `payroll.deduction-policy-view` | |
| POST | `/deduction-policies` | `payroll.deduction-policy-manage` | 201 |
| PUT | `/deduction-policies/{id}` | `payroll.deduction-policy-manage` | source_type immutable |
| PATCH | `/deduction-policies/{id}/status` | `payroll.deduction-policy-manage` | |
| DELETE | `/deduction-policies/{id}` | `payroll.deduction-policy-manage` | 409 if in use |
| POST | `/deduction-policies/evaluate-period` | `payroll.deduction-policy-evaluate` | `{ year, month, source_types? }` |
| POST | `/deduction-policies/{id}/evaluate` | `payroll.deduction-policy-evaluate` | manual/template sources |

### 9.2 Existing employee deductions

Keep current routes under `payroll.deduction-manage`.  
Response adds: `source`, `deduction_policy_id`, `origin_period_*`, `origin_ref`, `calculation_snapshot`.

Optional filter: `?source=policy&year=&month=`.

### 9.3 Permissions (new actions)

Under Payroll module actions:

| Slug | Purpose |
|---|---|
| `deduction-policy-view` | List/show policies |
| `deduction-policy-manage` | CRUD/status |
| `deduction-policy-evaluate` | Run evaluators / manual evaluate |

Keep `deduction-manage` for obligations CRUD (unchanged).

---

## 10. Attendance integration contract

### 10.1 Read model (v1)

Prefer **attendance snapshots** already used by payroll:

- `late_count`
- `absent_days`
- `unpaid_leave_days`
- working days context for day-rate divisor

If snapshot not frozen yet, evaluation endpoint returns 422 with clear message:  
`Attendance period is not frozen for {year}-{month}`.

### 10.2 What Attendance must NOT do

- Create `employee_deductions`  
- Know deduction money formulas  
- Embed fine rules in shift grace logic  

Grace minutes remain attendance classification only.

### 10.3 Future event (optional)

```text
MonthlyAttendanceFrozen { company_id, year, month }
  → Payroll listener queues evaluation job
```

v1 can use explicit API/command without event.

---

## 11. Frontend (design sketch only)

New Payroll nav section: **Deduction Policies** (mirror Attendance Policies panels).

- List by `source_type` tabs  
- Form: common fields + source-specific rule panel (like `LeaveRulePanel`)  
- Actions: Activate / Archive / Evaluate period  
- Employee Deductions list: show Source badge (`Manual` / `Policy` / `System`) + link to policy  

No FE implementation in this design phase.

---

## 12. Edge cases & validation

| Case | Behavior |
|---|---|
| Metric below threshold | No obligation |
| Metric meets threshold twice same month | One obligation (`skip_if_exists`) |
| Employee has no basic/gross | Fail that employee with structured error; continue others; summarize failures |
| Day divisor working days = 0 | Fail employee; do not create zero/infinite |
| Net pay cannot absorb installment | Existing skip + `needs_review` |
| Multiple policies same employee/period | Apply each unless `stacking` conflicts; order by `priority` ascending |
| Structure PF + policy fine same month | Both apply (different tracks) |
| Absence unpaid proration + absence fine policy | Config must document; default avoid double penalty |
| Policy effective_date mid-month | Eligible if `effective_date <= period_end` |
| Cancel obligation | Existing cancel; evaluator will not recreate if `skip_if_exists` and cancelled row still matches unique — **v1 rule:** unique includes only `status in (active,completed)`; cancelled allows recreate OR evaluator checks active only |

**v1 unique rule (service):** lookup active/completed for (policy, employee, period); cancelled does not block recreate.

---

## 13. Observability / audit

On create from policy, store `calculation_snapshot`:

```json
{
  "policy_id": 12,
  "policy_code": "LATE-3-1DAY",
  "source_type": "attendance_late",
  "period": { "year": 2026, "month": 9 },
  "metric": { "name": "late_count", "value": 4, "operator": "gte", "threshold": 3 },
  "inputs": { "basic": 30000, "working_days": 26 },
  "result": { "total": 1153.85, "installment": 1153.85, "method": "day_based" }
}
```

Activity log (optional v1): `payroll.deduction-policy.evaluated` with counts created/skipped/failed.

---

## 14. Phased delivery

### Phase A — Foundation
- `deduction_policies` table + CRUD API + ConfigRules for `attendance_late` + `fine`  
- Extend `employee_deductions` columns + backfill `source=manual`  
- `createFromPolicy` on EmployeeDeductionService  
- Resource/FE list badge  

### Phase B — Attendance late evaluator
- Period evaluate command/API gated on freeze  
- `AttendanceLateDeductionEvaluator`  
- Idempotency + snapshot  
- Feature tests with frozen snapshot fixtures  

### Phase C — Ops polish
- Jobs UI trigger  
- Event on freeze  
- Priority ordering documentation in payslip  

### Phase D — Expand sources
- `attendance_absence`, `damage`, assignment scopes / policy groups  
- Optional approval for policy-generated obligations  

---

## 15. Testing strategy

| Layer | Cases |
|---|---|
| Unit ConfigRule | Unknown keys, missing threshold, day_based without divisor |
| Unit Evaluator | 2 lates → no create; 3 lates → create; re-run → skip |
| Unit Amount | day_based basic/26; fixed; percentage of gross |
| Feature Policy CRUD | unique code, immutable source_type, delete 409 when linked |
| Feature Pipeline | freeze → evaluate → generate payslip → breakdown contains fine; regenerate idempotent |
| Regression | Existing `EmployeeDeductionTest`, `PayslipGenerationTest`, carry-forward approval tests green |

---

## 16. Backward compatibility checklist

- [x] Existing `/employee-deductions` request shape unchanged  
- [x] Existing enum `loan|advance|fine|other` unchanged  
- [x] Structure deductions / tax / advances unchanged  
- [x] New DB columns nullable + backfill  
- [x] Payslip apply path unchanged  
- [x] Attendance daily calc unchanged  

---

## 17. Open decisions (product)

Confirm before Phase B coding:

1. **Ops order:** Is monthly freeze always before payslip generate in production?  
2. **Day rate divisor default:** `month_working_days` vs fixed 30?  
3. **Same-month recovery vs next-month start** for auto fines?  
4. **Double penalty** policy for absence (proration + fine)?  
5. Should policy-generated fines require approval before becoming `active`? (v1 recommendation: **no**)

---

## 18. Recommendation (locked)

> Implement Deduction Policy as a **Payroll-owned authoring entity** (`deduction_policies`) using the **AttendancePolicy shape** (typed JSON config + status + effective_date), and use the existing **`employee_deductions` / entries / payslip applyInstallment** stack as the obligation and application layers. Attendance supplies metrics only. Structure components and tax remain separate tracks.

This yields minimum rewrite, clear module boundaries, and an extension point for every future deduction source.

---

## 19. Document map

| Doc | Role |
|---|---|
| This file | Technical design |
| `PAYROLL_MODULE_BUSINESS_LOGIC.md` §7.5 | Existing obligation behavior (update after implement) |
| `ATTENDANCE_MODULE_GUIDE_EN.md` §4.4 | Policy authoring pattern reference |
| Investigation (chat 2026-10-07) | Evidence base |

**Next step after approval:** task cards / migration + API implementation plan for Phase A only.
