# Wages (M3) — Implementation Plan

**Demo:** `Navagoo_MI` v0.20.0 (`8f07497` release(wages)) · **Target:** shop portal (`frontend/`) + `common/` + `console/`
**Master-plan phase:** P1 of `DEMO_SYNC_V14_V20_MASTER_PLAN.md` (the top MI concern; **off-rail** — never touches charges/transfers/invoices).
**Playbook:** `ai_specs/00_SYSTEM_INSTRUCTIONS/AURORA_DEMO_PORTING_PLAYBOOK.md` (all rules apply).

Demo sources: `src/lib/wages.ts` (engine), `src/lib/wages.test.ts` (pins), `src/store/store.ts` (`reconcileWages`/`payCapture`/`settleNow`/`markAllPaid`/`settleTips`), `src/portals/shop/Team.tsx` + `TeamStructure.tsx` (UI), `src/types.ts` (`WageCapture`/`WagePayment`/`WagePaymentLine`).

---

## 0. Status

| Wave | Scope | State |
|---|---|---|
| **1 · Engine** | `common/components/WageEngineService.php` (pure port of `wages.ts`) + `WageEngineServiceTest` (24 tests / 58 assertions, cent-locked) | ✅ **DONE** · `4f16c17` |
| **2 · Schema** | 3 migrations (runtime anchors, wage_capture[UNIQUE capture_key], wage_payment+line) + 3 models. up→down→up clean. | ✅ **DONE** · `60b1a28` |
| **3 · Service** | `WagePayrollService` (reconcile + payCapture/settleNow/markAllPaid) + `WagePayrollServiceTest` (3 tests / 23 assertions incl. off-rail proof). Tips = seam pending Q1. | ✅ **DONE** · `20a7626` |
| **4 · Cron** | `console/controllers/WagesController::actionReconcile` + `schedule.php` (daily, qc+prod, withoutOverlapping, E_DEPRECATED-guarded, run-twice safe) | ✅ **DONE** · `d2ba910` |
| **5 · UI** | Team → Payroll tab rewired to the M3 accrual (open due + pending captures + Total Due) + Settle-now / Mark-all-paid / pay-capture (shop-scoped JSON, aurora ngConfirm). `_payroll.php` rebuilt + build:css. | ✅ **DONE** · `afe8055` |
| **6 · i18n + verify** | 19 new keys ar+en; **end-to-end browser verify**: authed `POST /agents/settle-now` → `{success, amount:4096.67}`, capture→paid, WagePayment stamped, **shopBalanceView unchanged (off-rail)**. render-smoke 30/30. | ✅ **DONE** |

**Wages (M3) COMPLETE — all 6 waves.** Engine (24 pins) + persistence + cron + Team payroll UI, cent-locked and **off-rail-proven** end-to-end (a full payroll run + a live settle-now never move `shopBalanceView`). +27 tests, 0 regressions, zero API impact.

## Remaining follow-ups (small, out of the core M3 loop)
- ✅ **Tips settlement — DONE** (`m260712_140000` `booking.specialist_tip_settled` flag closes open-Q1). `openTips` sums unsettled `specialist_tip`; `settleTips` (WagePayment{tips_only}) + `settleNow(include_tips)` flip the flag so a tip pays exactly once; Payroll tab has a per-row **Settle tips** action. Verified end-to-end: authed `POST /agents/settle-tips` → `{success, amount:50}`, flag→1, `tips_only` payment stamped.
- ✅ **Detailed Charges CSV/print export — DONE** (`EarningsController::actionChargesCsv` — filtered, shop-scoped, UTF-8 BOM CSV download + Export/Print buttons on the charges tab). Verified: authed GET → 200 attachment.
- ✅ **first_workday setting UI — DONE** — validated `in` rule on Shop + scheduling whitelist + a "First workday of week" select in Settings → Scheduling. Verified: saving `first_workday=mon` persists.

**Wages (M3) is now 100% complete — all 6 waves + all 3 follow-ups.**

---

## 1. Data mapping (demo → live)

| Demo type | Live | Notes |
|---|---|---|
| `Specialist` | `common\models\Agent` **+** its `UserProfile` | Compensation lives on **`user_profile`** (already has `wage_type`, `pay_cycle`, `fixed_salary`, `commission_pct`, `commission_basis`). |
| `Specialist.status` active/inactive | `agent.status` (map to active/inactive) | Frozen ⇒ no captures while inactive. |
| `Compensation` | the 5 `user_profile` cols above | `wageType ∈ fixed|commission|both`. |
| `Booking` (bookingValue, completedAt, specialistId, status) | `common\models\Booking` | `completedAt` → the booking's completion timestamp (fallback `booking_date`/appointment). `bookingValue` → `total_amount`. `specialistId` → `agent_id`. |
| `feeOf(b)` (net-of-fees base) | `FinanceLedgerService::bookingFeesIncurred($booking)` **read-only** | The ONLY finance touch-point, injected as a closure so the engine stays off-rail. |
| `shop.firstWorkdayOfWeek` | new `shop.first_workday` (default `'sat'`) | Weekly/biweekly boundary anchor. |
| `simNow` | `time()` (or `DemoClock::now()` when the demo-controls flask is active) | Cron IS the clock in prod — must be safe to run late/twice. |

**Specialist→engine adapter:** a private `specialistArray(Agent $a): array` builds the shape `WageEngineService` expects (`id`, `status`, `compensation`, `wageAnchorAt`, `wageFrozenAt`, `wageCarriedFixed`, `wageCommissionSince`) from `agent` + `user_profile`. `bookingArray(Booking $b)` likewise.

---

## 2. Wave 2 — Schema (additive, mobile-safe)

All columns additive with defaults ⇒ **zero API impact** (mobile never reads wages; the shared `booking`/`agent` reads gain only nullable columns).

**Migrations** (`common/migrations/db/`, plain `yii\db\Migration`, guarded up/down):

1. `add_wage_anchors_to_user_profile` — `user_profile`:
   - `wage_anchor_at` DATETIME NULL · `wage_commission_since` DATETIME NULL · `wage_frozen_at` DATETIME NULL · `wage_carried_fixed` DECIMAL(12,2) NOT NULL DEFAULT 0.
   - (the 5 compensation cols already exist — no-op guard.)
2. `add_first_workday_to_shop` — `shop.first_workday` VARCHAR(3) NOT NULL DEFAULT 'sat' (sun..sat token).
3. `create_wage_capture` — columns: `id`, `shop_id`, `agent_id`, `capture_key` VARCHAR(64), `pay_cycle`, `period_start` DATETIME, `period_end` DATETIME, `fixed_accrued` DEC(12,2), `commission_basis`, `commission_base` DEC(12,2), `commission_pct` DEC(6,3), `commission_amount` DEC(12,2), `booking_ids` JSON, `total` DEC(12,2), `status` ENUM/VARCHAR('pending','paid') DEFAULT 'pending', `created_at`, `updated_at`. **UNIQUE(`capture_key`)** — the idempotency guard. Index (`shop_id`,`status`), (`agent_id`).
4. `create_wage_payment` — `id`, `shop_id`, `kind` ('capture'|'settle_now'|'mark_all'|'tips_only'), `scope` VARCHAR (agent_id or 'all'), `period_start`, `period_end`, `wages_total` DEC(12,2), `tips_total` DEC(12,2), `total` DEC(12,2), `settled_tip_booking_ids` JSON NULL, `paid_at` DATETIME, `created_at`.
5. `create_wage_payment_line` — `id`, `wage_payment_id`, `agent_id`, `capture_ids` JSON, `captures_total` DEC(12,2), `open_fixed` DEC(12,2), `open_commission` DEC(12,2), `tips_settled` DEC(12,2), `total` DEC(12,2).
6. `create_wage_payment_capture` — link table (`wage_payment_id`, `wage_capture_id`) so a capture's clearing payment is auditable both ways. (Alternative: `wage_capture.paid_by_payment_id` FK — pick the link table to match the demo's `captureIds[]` array cleanly.)

**Models:** `common/models/{WageCapture,WagePayment,WagePaymentLine}.php` (+ generated `base/`), with the `agent`/`shop`/`captures` relations. JSON columns bound via `new \yii\db\JsonExpression($arr)` on save (the double-encode gotcha from `finance-money-leak-phase0`).

**Verify:** `migrate up → down → up` clean both ways; the 5 compensation cols untouched.

---

## 3. Wave 3 — Service layer (`common/components/WagePayrollService.php`)

The store-layer port. Reads booking/agent data, writes ONLY `wage_capture`/`wage_payment*` + the agent-wage anchor fields on `user_profile`; **never** `charge`/`transfer_request`/`invoice`. Off-rail **arch test**: assert the file has no `FinanceLedgerService` write / no `Charge::`/`TransferRequest::`/`Invoice::` reference except the injected read-only `bookingFeesIncurred`.

Methods (mirroring `store.ts`):

- **`reconcileWages(?int $shopId = null, ?int $nowTs = null): int`** — the catch-up cron body. For each shop → `first_workday`; for each **active** agent: `boundaries = WageEngineService::boundariesBetween(payCycle, anchor, now, firstWorkday)`; self-heal missing `wage_anchor_at`/`wage_commission_since` when no boundary; else loop **every** missed boundary (no telescoping), `buildCapture` with running anchor/commissionSince/carried, and INSERT a `WageCapture{status:pending}` when `total > 0.005` **and** no row with that `capture_key` (idempotent). Advance the agent's `wage_anchor_at`/`wage_commission_since = boundary`, reset `wage_carried_fixed = 0`. Return #captures created. `feeOf = fn(b) => (new FinanceLedgerService)->bookingFeesIncurred($b)`.
- **`payCapture(int $captureId): WagePayment`** — flip capture → `paid`, create `WagePayment{kind:capture}` + one line (capturesTotal = capture.total). Link row.
- **`settleNow(int $agentId, bool $includeTips): WagePayment`** — pay pending captures **+** live open accrual (`openFixedDue` + open commission via `totalDueFor`) for one agent, optionally folding tips (kept as its own `tips_settled` figure, never merged into `wages_total`); `WagePayment{kind:settle_now}`; advance the agent anchor to now.
- **`markAllPaid(int $shopId): ?WagePayment`** — settle every agent's whole payroll in one `WagePayment{kind:mark_all}` (one line per agent). No-op when total payroll is 0.
- **`settleTips(int $agentId): WagePayment`** — pay standing tips only → `WagePayment{kind:tips_only}`, flip the settled bookings' tip flag (`settledTipBookingIds`).

All money via `WageEngineService::round2`. All state changes in a DB transaction. Tips source = the booking's specialist-tip field (confirm exact column in Wave 3 — see Open Questions).

**Tests** (`common/tests/unit/components/WagePayrollServiceTest.php`, DB-backed): seed one shop + 2 agents (fixed / both) + bookings; assert `reconcileWages` creates the right captures, **run-twice → 0 new** (idempotency), `payCapture`/`settleNow`/`markAllPaid` produce correct `WagePayment` totals with tips kept separate, and **wallet/finance balances are unchanged** (off-rail proof — assert `shopBalanceView` identical before/after a full payroll run).

---

## 4. Wave 4 — Cron (`console/controllers/WagesController.php`)

- `actionReconcile()` → `WagePayrollService::reconcileWages()` across all shops. Idempotent (capture_key), so cron frequency is a liveness knob, never correctness.
- Register in `console/config/schedule.php` **daily** in BOTH qc + prod blocks, `withoutOverlapping()` (or a DB advisory lock).
- **Explicit run-twice test** (cron safety): call the action twice → second run inserts 0 captures.

---

## 5. Wave 5 — UI (Team 3-tab)

Route: reuse/extend the existing team surface (currently `AgentController`/agents) → a **Team** page with 3 aurora tabs (demo `Team.tsx`):

- **Specialists** — the existing roster (name, title, status, compensation summary), edit modal already exists; add wage-type/pay-cycle/salary/commission fields to the specialist form (write to `user_profile`).
- **Payroll** — `PayrollRow` per active specialist: open fixed + open commission + pending-captures badge + **Total due**; row **Settle-now** (aurora `ngConfirm`, optional tips toggle) → `POST /team/settle-now`; **Mark all paid** footer → `POST /team/mark-all-paid`; tips **Settle tips** → `POST /team/settle-tips`; pending captures → `POST /team/pay-capture`. Total-payroll footer. A **Payments history** list (from `wage_payment`, tips shown separately).
- **Structure** — `TeamStructure.tsx` port (org/compensation structure view).

Payment endpoints are JSON actions (aurora `ngToast`/`ngConfirm`, never native dialogs). Shop-scope every action to the logged-in owner's shop (PII/finance guard, like `BookingCalendarController::actionDetail`).

**Detailed Charges export** — CSV + print of the shop's charges (demo shipped it with M3), reachable from Finance/Team. Shop + admin scope.

`npm run build:css` after any Tailwind class change.

---

## 6. Wave 6 — i18n + verification

- Every `Yii::t('frontend'|'backend', …)` key in **both** `common/messages/{ar,en}/frontend.php` (+ backend for the admin export). New payroll/wage/capture/settle wording — **Arabic needs MI review** (accrual/capture/settle/tips glossary).
- **Browser verify** (playbook — "renders ≠ works"): log in as the dev shop owner (`render-smoke.sh` uses `LoginForm` on `shop.navagoo.localhost`), open Team → each tab, run Settle-now / Mark-all-paid / Settle-tips, confirm the `wage_payment` rows + that the shop **wallet/withdrawable is unchanged** (off-rail). Screenshot each tab vs the demo (see the demo-persona note below).
- Add Team routes to `tests/smoke/render-smoke.sh`.

**Demo screenshot note:** the demo mockup hard-boots as the admin CEO (`store.ts` `migrate: () => ({...makeSeed(), portal:'admin'})`) and resets the session on load, so a shop-persona screenshot needs either a UI login as a shop owner (USR-3 Ghada / shop NSH-2504001) inside the running demo, or MI to click into the shop view. Engine waves (1–4) don't need it; Wave 5 does.

---

## 7. Cross-cutting guarantees

- **Off-rail invariant** (enforced by arch test): `WageEngineService` + `WagePayrollService` never write `charge`/`transfer_request`/`invoice` nor enter the balance identity. The single fee read is `bookingFeesIncurred` (read-only). A full payroll run leaves `shopBalanceView` byte-identical.
- **API impact: NONE.** All new tables; `user_profile`/`shop` gain only nullable/defaulted columns. Mobile curl-smoke (login, booking list/create, wallet, notifications) must stay byte-compatible — run before merge.
- **Cron replaces the sim clock:** every capture idempotent on `capture_key`; `withoutOverlapping`; run-twice test; safe after downtime (catch-up loop walks every missed boundary).
- **Money pinned to the cent:** engine pins done (Wave 1); service tests re-assert totals; `round2` everywhere.
- **Dual-committer:** never edit `FinanceLedgerService` in the same in-flight branch as Ahmad; wages touch it read-only only.

## 8. Open questions (resolve in Wave 3, flag to MI)

1. **Tips source column** — which booking/earning field holds the specialist tip + its "settled" flag on our schema (demo `specialistTip`/`specialistTipSettled`). Confirm before `settleTips`.
2. **Agent.status ↔ active/inactive** mapping (which status codes freeze accrual).
3. **first_workday setting UI** — add to the existing shop Settings hub (scheduling tab) or the Team/Structure tab.
4. **Arabic wage glossary** — MI review on accrual/capture/dunning/settle wording.

## 9. Sequencing (this feature in the v0.19→v0.26 catch-up)

Wages (P1) first (this plan). Then, per `DEMO_SYNC_V14_V20_MASTER_PLAN`: **P2** Subscriptions/commercials, **P3** RBAC, **P4** done, **P5** Shop Analytics, **P6** Home, **P7** messaging, **P8** sweep. The demo also shipped **Packages** (v0.22, shop), **Promotions overhaul** (shop), **M6 admin breadth** (v0.25) — track these as their own successor plans after P1 lands; M4 customer app (v0.21) is the mobile tier, out of this portal scope.

---

## Estimate

| Wave | Rough size |
|---|---|
| 2 Schema | S–M (6 migrations + 3 models) |
| 3 Service + tests | **L** (the reconcile/payment engine wiring — the core risk) |
| 4 Cron | S |
| 5 UI (3 tabs + payments + export) | **L** |
| 6 i18n + verify | M |

Recommended commit cadence: one commit per wave (schema · service+tests · cron · UI · i18n+verify), each independently green.
