# NVG-BEA-010 — Specialist Wages & Commission — Gap Analysis + Implementation Plan

**Status: COMPLETE — started 2026-07-23, finished 2026-07-26 (W4).** Live tracker at the bottom.

> **W4 headline — commission-zeroing bug FIXED:** `WagePayrollService` (and the W3 api
> endpoint) passed INT ids into `WageEngineService::commissionInWindow()`/`buildCapture()`
> whose docblock contract declares STRING ids and whose `!==` filters compare strictly —
> PHP coerces the int `$shopId` ARGUMENT to string at the declared-param boundary, but int
> VALUES inside the specialist/booking arrays stay ints, so `int !== string` always
> mismatched and **every commission through the real reconcile/live-due path was silently
> zeroed** (fixed salary unaffected). Fixed at BOTH levels: string-normalized ids at the
> WagePayrollService boundary (per the engine contract) AND type-tolerant `(string)` casts
> on both sides of the engine's id comparisons (this second fix also repairs the api-tier
> `WalletController::actionWages`, which bakes int ids, WITHOUT an api/ edit). Regression
> tests: engine-level int-id test + full-path reconcile test. CLI proof on dev DB: seeded
> SAR 230 booking → `wages/reconcile` stamped capture base 200.00 / commission **20.00**
> (the BRD AC); second run 0 created (idempotent); all seeded rows restored.
Spec: NVG-BEA-010 BRD (user-provided). Audited 2026-07-23 (agent, file:line evidence).

## Verdict: engine + config largely DONE (M3 wage system); BRD-shaped payroll view, basis alignment, and the specialist Wallet are the gaps

### DONE
- Schema on `user_profile`: wage_type (fixed|commission|both) + pay_cycle + fixed_salary +
  commission_pct + commission_basis (+ M3 accrual runtime cols). All 3 BRD types.
- Team→Agents Compensation card (all fields, JS toggles) — `agents/_form.php:911-960`.
- Daily `wages/reconcile` cron → idempotent `wage_capture` rows; `wage_payment(+_line)`
  settlement records; explicitly OFF-RAIL (display/record only — matches "Navagoo does
  not process payroll").
- Per-booking commission engine (`WageEngineService::commissionInWindow` — completed
  bookings by that specialist; group bookings already one row per specialist).
- Tips per booking (`booking.specialist_tip` + settled flag) feeding the wage engine.

### GAPS
1. **Payroll page shape**: current Team→Payroll = rolling "open since last settlement"
   accrual (M3), no calendar-month selector, and lacks the BRD columns
   (fixed_salary / service_commission_earned / tips_earned / total_earnings).
   The old month-bucket code was superseded — BRD wants a month **summary view**.
2. **Commission basis mismatch**: engine sums VAT-inclusive `booking.total_amount`; BRD
   says pre-VAT post-discount (exists as `booking_service.price_excl_vat_after_discount`,
   unwired). Payroll is off-rail/display-only → aligning is low-risk (new captures only;
   existing wage_capture rows immutable).
3. **SPECIALIST_APP Wallet**: api agent wallet has zero wage/commission data. **[api-tier
   — additive new endpoint, mobile team builds UI; flagged per project rule]**
4. Two disconnected tip subsystems (M3 `booking.specialist_tip` vs legacy
   AgentWithdrawal/Transaction tipping) — M3 is authoritative for payroll numbers
   (documented); legacy remains for transfer-request history.
5. Duplicate `user.wage_type` column (admin People display) vs the real
   `user_profile.wage_type` — display should read user_profile (small fix).

## Waves
- **W1 Payroll month summary (frontend):** add a calendar-month period selector (default
  current month) + a BRD-shaped summary table (specialist · fixed_salary ·
  service_commission_earned · tips_earned · total_earnings) ABOVE the existing M3
  settlement tool (which stays untouched — it's the payment workflow; the summary is the
  BRD report). Month math via WageEngineService windows + a month-scoped tips query.
  Fix backend People "wage type" to read user_profile.
- **W2 Basis alignment (common):** commission basis 'service_value' → pre-VAT
  post-discount (sum `price_excl_vat_after_discount`, fallback to derived no-VAT value
  when null); 'net_of_fees' unchanged in spirit (net of Navagoo fees on the same pre-VAT
  base). Update engine tests. Documented as BRD alignment (SAR 200 × 10% = SAR 20 AC).
- **W3 Specialist Wallet endpoint (api-tier, ADDITIVE ONLY):** new
  `/agent/wallet/wages` action (new code only, zero edits to existing actions):
  period (default current month, date_from/date_to pattern reused) →
  {wage_type, fixed_salary, service_commission_earned, tips_earned, total_earnings} from
  WagePayrollService/WageEngineService. API_CONTRACTS.md section. Mobile team flag.
- **W4 Translations + unit tests (SAR 200@10%→20 AC, month windows, basis) + smoke +
  docs.**

### Sequencing
W1 ∥ W2 ∥ W3 (frontend / common / api — disjoint) → W4.

### Out of scope (per BRD)
Payroll payment processing · per-service rates · payroll export/accounting integrations.

## Live tracker
- [x] W0 audit + this plan
- [x] W1 payroll month summary — DONE 2026-07-23. `frontend/controllers/AgentsController.php`
      (`_computeMonthSummary()`, `?month=Y-m` GET param, default current calendar month) +
      `frontend/views/agents/_payroll.php` (new "Monthly summary" section, read-only,
      ABOVE the untouched M3 settlement table) + `frontend/views/agents/index.php`
      (forwards `monthSummary`; `?tab=` persisted via the existing `data-ng-tabs-active`
      convention so the month-nav reload lands back on Payroll). Backend People
      "Wage Type" fixed to read `user_profile.wage_type` (`backend/controllers/
      UserController.php` eager-loads `userProfile`; `backend/views/user/people.php`
      reads it, falls back to `wageBadge(null)` → "—"). Commission via
      `WageEngineService::commissionInWindow()` (public, untouched) — built specialist/
      booking arrays locally with STRING ids (per the engine's own docblock contract)
      to sidestep a real int/string type-coercion bug found in `WagePayrollService`
      (`totalDueFor`/`commissionInWindow` calls pass int shopId/specialistId against
      string-typed params, so the `!==` shop-id guard never matches under weak typing —
      flagged, not fixed here since `WagePayrollService`/`WageEngineService` internals
      are out of scope for W1). Fixed salary only folds into the total when
      pay_cycle=monthly (footnoted otherwise, no conversion math). New
      `Yii::t('backend', ...)` keys (NOT yet added to message files, per scope):
      'Monthly summary', 'Fixed salary, commission and tips earned per specialist for
      the selected month', 'Previous month', 'Next month', 'Select month',
      'Commission Earned', 'Fixed salary shown at its configured pay-cycle amount (no
      conversion to a monthly figure) and excluded from Total Earnings for non-monthly
      cycles.'. Verified: `php -l` clean on all 5 touched files; `npm run build:css`
      rebuilt; curl smoke — shop-owner login → `/agents/index` 200 w/ "Monthly summary"
      + Fixed/Commission/Tips/Total columns; `?month=2026-06&tab=payroll` 200 w/ month
      input value=2026-06, label "June 2026", tab persisted; figures match the existing
      M3 table's Open Fixed for the same test specialists (both 0 — real test-data, not
      a bug). Backend `/user/people?tab=specialists` 200, wage badges now correctly
      split "Fixed + Commission" vs "—" per specialist (previously all read the stale
      duplicate `user.wage_type` column).
- [x] W2 commission basis alignment — done 2026-07-23. `WagePayrollService::bookingArray()` now
  resolves `bookingValue` as pre-VAT/post-discount: sums `booking_service.price_excl_vat_after_discount`
  (via new `preVatLineTotals()`) when ALL of a booking's line rows carry it, else falls back to
  `WageEngineService::stripVat()` (new pure helper, mirrors `FinanceLedgerService::noVat()`'s
  formula without depending on it) off `total_amount`, using CommercialConfig.vat_pct /
  Settings.taxes. `net_of_fees` unchanged in code — it now nets off the same new pre-VAT base
  automatically. New-captures-only; existing `wage_capture` rows untouched (capture_key gate
  unchanged). Tests: 5 new pins in WageEngineServiceTest (stripVat, sumPreVatLines, BRD AC
  200@10%=20, 230-incl-15%-VAT fallback, net_of_fees consistency) + 3 new DB-wired tests in
  WagePayrollServiceTest (sum-of-lines preferred, fallback on missing/partial lines). Full unit
  suite baseline unchanged (396 tests, 6 errors/15 failures, none wage-related — pre-existing
  OTP/Aurora/Calendar/token fixtures). `wages/reconcile` run clean against dev DB, idempotent.
- [x] W3 specialist wallet endpoint (additive, flagged) — `GET /agent/wallet/wages`
      in `api/controllers/agent/WalletController.php::actionWages` (new method) +
      route in `api/config/urls/_AgentUrls.php`; calls
      `WageEngineService::commissionInWindow` (public API only, no math duplicated);
      docs in `ai_specs/03_API/API_CONTRACTS.md` §3.3; unauth curl → 401; 24/24
      `WageEngineServiceTest` + 43 `FinanceLedgerServiceTest` + 10
      `EntitlementServiceTest` green; `git diff --stat api/` scoped to
      WalletController + `_AgentUrls.php` (plus a pre-existing unrelated
      NVG-BEA-011 `_urlManager.php` diff from before this wave)
- [x] W4 translations + tests + verify — DONE 2026-07-26.
      **(1) Commission-zeroing int/string id bug FIXED (see headline box at top):**
      `WagePayrollService::specialistArray()/bookingArray()` now emit STRING ids and
      `buildCapture`/`totalDueFor` get a string shopId (the engine's docblock contract);
      `WageEngineService::commissionInWindow()` additionally string-normalizes both sides
      of its id comparisons (fixes the int-id-baking api `WalletController::actionWages`
      with zero api/ edits). Regression tests:
      `WageEngineServiceTest::testCommissionIsComputedWithIntegerIdInputs` (pure engine,
      int + mixed ids) and
      `WagePayrollServiceTest::testReconcileComputesCommissionThroughRealIdPlumbing`
      (full AR→capture path: SAR 230 booking ⇒ capture commission 20.00 + live-due
      openCommission 20.00). CLI proof: seed → `wages/reconcile` = 1 capture
      (base 200.00, commission 20.00, booking counted), rerun = 0 (idempotent), restored.
      **(2) W4 bonus basis fix (exposed by the new month-summary test):** W1's
      `_computeMonthSummary` fed VAT-inclusive `total_amount` into the engine — the shop's
      Monthly summary showed 23 where the W2-aligned capture stamps 20 for the SAME
      booking. Now resolves the same pre-VAT chain as `WagePayrollService::bookingArray()`
      via the engine's pure helpers (line-sum preferred, `stripVat` fallback,
      CommercialConfig→Settings rate, shop `is_taxable`). NOTE: the api W3 endpoint still
      feeds `total_amount` (VAT-inclusive) — flagged, not fixed (api/ frozen; additive
      endpoint, mobile team not yet consuming; align in a follow-up api wave).
      **(3) Translations:** the 12 missing backend-category keys (7 W1 + 5 W3) added to
      `common/messages/{en,ar}/backend.php` (real Arabic, placeholders verbatim); parity
      script + i18n hook both clean — 0 missing across backend/frontend/common × en/ar.
      **(4) Tests:** + `AgentsControllerMonthSummaryTest` (3 tests: month-window
      in/out + BRD columns + pre-VAT basis; non-monthly fixed excluded+footnoted;
      invalid month fallback — reflection pattern per AdminChargeControllerTest).
      Full unit suite 401 tests (baseline 396 + 5 new), errors/failures unchanged at
      6/15 (pre-existing Aurora/CalendarFormat/SmsLog/TokenExpiration — none wage-related).
      **(5) Smoke:** shop-owner login → `/agents/index?tab=payroll` 200 with Monthly
      summary + Fixed/Commission/Tips/Total columns (EN) and الملخص الشهري/العمولة
      المكتسبة/الشهر السابق/الشهر التالي/اختر الشهر rendering (AR);
      backend `/user/people?tab=specialists` 200 (نوع الأجر column + ثابت/عمولة badges
      from user_profile); api unauth `GET /agent/wallet/wages` → 401.
