# Contract-Owned Rule Authoring — Implementation Plan

**Status:** ✅ Blocks A–E complete (23 Sep 2026).
**Created:** 23 Sep 2026
**Builds on:** `CONTRACT_CENTRIC_COMMISSION_ARCHITECTURE_PLAN.md` (phases 0–9 complete)

**Process rule:** one block → test → report → **STOP** → approval → next block.

---

## 1. Goal

Author a contract's commission rules **on the contract**, with the full form, without first
creating a template. Templates remain as an optional shortcut that prefills the form.

```text
Now:   Rule Templates → create template → Contract → Commission → pick template → copy
Target: Contract → Commission → Add Rule → fill the form here
        (optionally: "Prefill from template" → then edit freely)
```

## 2. Locked decisions

| ID | Decision |
|----|----------|
| **A1** | Templates stay, as an **optional prefill** only. Template Administration page is untouched. |
| **A2** | `scope` and `priority` are **removed from the contract form**. A contract rule is contract-scoped by definition, and ordering is `sort_order`. Both are set internally. |
| **A3** | Existing template-copied rows are test data. **No backward compatibility or data migration is required.** |
| **A4** | UX: **full-page master-detail** at `/project/contracts/{contractId}/commission` — rule list left, selected rule's form right. No drawer-inside-drawer. |
| **A5** | No schema change. `policy_snapshot` already holds every field; `template_rule_id` stays nullable and becomes optional provenance. |
| **A6** | No engine change. `RuleResolver` already resolves a snapshot row with no template (covered by `snapshot_binding_with_missing_template_still_resolves`). |

## 3. Form sections to support (same as the template form)

| Section | Snapshot fields |
|---|---|
| Basic info | `name`, `team_division`, `role_designation_id` |
| Calculation | `commission_type`, `calculation_base`, `percentage` \| `fixed_amount` |
| Timing | `frequency`, `duration_type`, `duration_value`, `start_condition`, `effective_from`, `effective_to` |
| Payout split | `allocation.salary_percent`, `allocation.fund_percent`, `allocation.payout_mode` |
| Who can receive | `eligibility.block_probation`, `eligibility.require_confirmation` |
| When BD period ends | `redistribution.enabled`, `redistribution.destinations[]` (send leftover money to) |
| Employee fund unlock | `unlock.enabled`, `unlock.steps[]` (unlock plan) |
| If employee leaves | `termination.pay_unlocked`, `termination.forfeit_locked`, `termination.pay_locked` (still-locked money) |

Not in the contract form (A2): `scope`, `priority`, `code`, `service_id`, `team_id`, `contract_id`,
`employee_id` — the last four are runtime-matching fields that a contract rule does not need.

Internally set on save: `scope = 'contract'`, `priority = 0`, `contract_id` = the contract,
`status` = the row's own active/inactive flag.

---

## 4. Blocks

### Block A — Backend: author a rule from raw fields ✅ DONE (`13ed9697`)

**Do**
- `ContractPolicySnapshot::fromInput(array $data): array` — build the snapshot from request fields, same vocabulary as `fromTemplate()`.
- `ContractCommissionConfigurationService::addRule()` / `updateRule()` accept either a `template_rule_id` (existing behaviour) **or** a full field payload; `template_rule_id` stays as provenance when present, null otherwise.
- `AddContractCommissionRuleRequest` / `UpdateContractCommissionRuleRequest`: validate the whole field set — nested `eligibility`, `allocation`, `redistribution.destinations[]`, `unlock.steps[]`, `termination`; keep the existing salary+fund pairing and sum-to-100 checks.
- `updateRule()` must be able to replace the whole snapshot, not only overrides.

**Do not:** change schema, `RuleResolver`, engines, the template API, or the Template Administration page.

**Acceptance:** a rule can be created on a contract with no `template_rule_id`; its snapshot carries every section; the engine calculates from it; editing changes only that row; template-prefilled rules still work.

**STOP.**

### Block B — Frontend: extract the shared rule form ✅ DONE (`79fa0e66`)

Extract the 9 sections out of `CommissioningRulesPage` into a reusable `CommissionRuleForm`
(value + onChange + which fields to hide), reusing `commissionRuleFormCopy` (en/bn) as-is.
`CommissioningRulesPage` then renders the same component — **no behaviour change there**.

**Acceptance:** template page behaves exactly as before; tsc/lint/build clean.
**STOP.**

### Block C — Frontend: contract commission page ✅ DONE (`9f70f25e`)

- Route `/project/contracts/{contractId}/commission`, master-detail (A4).
- Left: rule list (name, division, summary, status) + "Add Rule".
- Right: `CommissionRuleForm` with `scope`/`priority` hidden (A2), plus a **"Prefill from template"** picker that loads an active template into the form for editing (A1).
- Save → `POST/PUT /contract-commissions/{id}/rules`.

**Acceptance:** a contract rule can be created, edited and removed end to end without visiting the Templates page.
**STOP.**

### Block D — UX cleanup ✅ DONE (`8fa3d19b`)

- Contract drawer Commission tab → allocation summary + rule summary + link to the page; **remove the duplicated Team Assignment panel** (it already has its own tab).
- `/commissioning/commissions/{contractId}` → redirect to the new page, so there is one door.

**STOP.**

### Block E — Regression gate ✅ DONE

Commissioning + Project suites, frontend tsc/lint/build, and a real calculate on a contract whose
rule was authored inline.

**STOP.**

---

## 5. Out of scope

- Schema or migration changes
- Engine / formula changes
- Template Administration redesign or removal
- `commission_duration` wiring (Q6), Q5 physical move, repository refactor (Item 2)
- Fixing the retired Recorded Earnings endpoint
- Enabling `COMMISSIONING_REQUIRE_APPLIED_CONFIG`

## 6. Risks

| Risk | Mitigation |
|---|---|
| Extracting the form breaks the Template page | Block B ships with no behaviour change and is verified before Block C uses it |
| Validation drift between template rules and contract rules | one shared request rule-set, used by both paths |
| A contract rule with no division/designation matches every member | same predicate as today (null = matches all); documented in the form hint |
| Large form on a contract with no allocation | page shows the allocation state and links to Team Assignment |

## 7. Next action

All blocks are done. Results:

| Check | Result |
|---|---|
| Modules/Commissioning | 138 passed (912 assertions) |
| Modules/Project | 301 passed (1532 assertions) |
| Full backend | **1239 passed (5128 assertions)**, 0 failed |
| Frontend | `tsc -b` clean, oxlint 0 errors, style boundary clean, vite build OK |
| Live check (dev DB, contract 3) | rule authored with no template → snapshot stored → RuleResolver returned `applied_configuration` / `contract_snapshot` at 10%. Verification row removed afterwards |

**Verification correction:** earlier reports in this programme said "tsc clean" based on
`tsc --noEmit -p .`. The root `tsconfig.json` is a solution file with `files: []`, so that command
checked nothing and always exited 0. `tsc -b` is the real check and is used from Block B onwards;
running it also surfaced a pre-existing type error in `CommissioningCommissionsPage` (`per_page`
passed to `PaginationMeta`), fixed in `79fa0e66`.

Remaining, each needing its own approval: Q5 physical move, Q6 `commission_duration`, repository
`$this->model` alignment, enabling `COMMISSIONING_REQUIRE_APPLIED_CONFIG` at first deploy.
