# NVG-BEA-002 — Payment Processing Fee (Gateway Cost Pass-Through) — Gap Analysis + Implementation Plan

**Status: COMPLETE (W1–W6) — 2026-07-22, uncommitted.** Live tracker at the bottom.
Spec source: NVG-BEA-002 BRD (user-provided). This doc records what already exists,
what's missing, and the wave plan to close it.

## Verdict on "is this done?"

**No — ~40% foundation exists, and the fee engine is dormant in the main booking flow.**

### Already DONE (previous parity waves)
| Spec item | Where |
|---|---|
| Global config (rate % + fixed SAR) + admin UI | `commercial_config.processing_fee_pct` / `.fixed_fee_sar` + `backend/views/commercial-config/index.php` |
| Immutable stamped fee rows | `Charge` (TYPE_PROCESSING_FEE) with `rate_snapshot` / `fixed_snapshot` — stamped on the charge, satisfying forward-only pricing *when the row is created* |
| Fee math (collected × rate% + fixed, VAT on top, round2) | `FinanceLedgerService::buildProcessingFee()` |
| Pay-on-Visit → no fee; deposit-only basis | `buildProcessingFee` returns null when `amountCollected()<=0`; basis = amountCollected |
| Shop Portal → Settings → Commercials (3 lines) | `frontend/views/settings/_commercials.php` |
| Settlement summary deduction line (earnings view) | `frontend/views/earnings/_settlement.php` (collected + tips − marketing − processing − VAT) |
| P&L / BI integration | `PlatformPnlService` ('processing'), `PlatformBiService` |
| Customer never sees the fee | fee exists only in shop/admin surfaces |

### GAPS (audit 2026-07-22, agent-verified with file:line evidence)
1. **CRITICAL — fee never created for normal bookings.** `deriveBookingCharges()` is only
   called from: group-booking creation, cancel flows, classification override, demo billing.
   The solo-booking creation (api `BookingForm`), payment-collection (`actionCollect`,
   payment callbacks) and completion (`BookingCompletionService`) paths never derive charges
   → no processing-fee row for a normal completed booking.
2. **No per-shop override.** `shop.payment_processing_fee_rate/_fixed` columns don't exist
   (ledger accessors already check them via `isset()` — always false today). No admin form
   fields. No floor (2.5%+0.50) / hard cap (5%+1.50) validation anywhere (global config has
   only `min => 0`).
3. **No rate-change audit log** (old/new/reason/admin) and **no email notice**.
4. **No effective-date mechanism** (7-day advance; forward-only scheduling). Config save is
   immediate.
5. **Settlement Request page** (`agents-wallet/create` → `_form.php`) has **no PPF column**
   and its net formula **omits the PPF** — inconsistent with `earnings/_settlement.php`.
6. **Earnings per-booking table** has no PPF column (folded into a tooltip only).
7. **No settlement PDF** exists at all (only backend invoice PDF via Mpdf).
8. **refund_reason** exists only as free text on `cancellations_and_refunds.reason`; the
   frontend cancel flow doesn't touch that table; **no absorption logic** — processing fee
   is doc-commented "non-refundable; never reverses".

### API-impact note (shared mobile tier)
All schema changes are **additive nullable columns / new tables** — no API contract change.
The W2 hook adds Charge-row derivation inside shared flows used by `api/` booking cancel —
additive writes, response shapes untouched. Flagged per project rule; no mobile breakage
expected.

## Wave plan

- **W1 — Schema (migration, additive only):**
  `shop`: `payment_processing_fee_rate` `payment_processing_fee_fixed` (nullable decimal) +
  `ppf_pending_rate` `ppf_pending_fixed` `ppf_effective_date` (nullable).
  New `commercial_rate_log` (scope global|shop, shop_id null, field, old/new, reason,
  admin_id, effective_date, created_at).
  `booking.refund_reason` + `cancellations_and_refunds.refund_reason` (nullable varchar,
  enum enforced in model).
- **W2 — Fee engine correctness (ledger + hooks):**
  Stamp/derive processing fee in the REAL flows: payment-success/collection + completion +
  walk-in/api creation (when collected>0). Effective-date-aware accessors:
  `processingRatePct/Fixed` prefer per-shop override; apply pending rate only when
  `now >= ppf_effective_date` (lazy, no cron). Keep every change additive in
  FinanceLedgerService (2-committer file — additive methods only).
- **W3 — Admin (rate governance):**
  Shop form: override fields + floor/cap validation + sub-floor requires super-admin
  confirm + mandatory reason. CommercialConfig: cap validation + audit logging.
  Every rate change → `commercial_rate_log` + owner email (new rate + effective date,
  min 7 days enforced). Bilingual strings.
- **W4 — Shop portal surfaces:**
  Commercials page: upcoming rate + effective date. Earnings table: PPF column.
  `agents-wallet/_form.php` + controller: PPF column + "Total Payment Processing Fees"
  card + net formula aligned to `_settlement.php` (collected + tips − marketing − PPF − VAT).
  Settlement PDF (Mpdf, per-booking PPF breakdown) following backend PaymentController
  pattern.
- **W5 — Refund reason + absorption:**
  Capture `refund_reason` in both cancel paths (frontend auto: customer/shop cancellation,
  no_show; admin refund UI: dropdown incl. navagoo_goodwill / platform_issue).
  `buildProcessingFeeReversal()`: reversal row when reason ∈ {navagoo_goodwill,
  platform_issue} (Navagoo absorbs); basis = original online payment amount.
- **W6 — Tests + verify:**
  Unit: SAR 8.00 example, stamped forward-only, cap block, sub-floor gate, goodwill
  absorption, pay-on-visit zero. `codecept run unit`. Browser smoke admin + shop.

### Sequencing
W1 → W2 → (W3 ∥ W4) → W5 → W6. W3/W4 parallel-safe (backend vs frontend files).

### Deferred (per spec)
BNPL rates · customer-facing display · automated Paymob reconciliation · OQ-4/OQ-5
(Paymob refund-fee behaviour — owner: Muhannad).

## Live tracker
- [x] W0 audit + this plan
- [x] W1 schema (m260722_100000 applied; shop cols + commercial_rate_log + refund_reason)
- [x] W2 fee engine — 2026-07-22. `FinanceLedgerService::processingRatePct/Fixed` now
      resolve the effective-date-arrived `ppf_pending_*` columns first, then the
      per-shop override (guard changed from `isset()` to explicit `!== null && !== ''`,
      matching the sibling accessors — behaviourally identical since AR's `__isset`
      already delegated to `!== null`, but now self-documenting), then CommercialConfig,
      then the CEO default. New `ppfPendingEffective(Shop)` does the lazy
      today-in-app-tz >= ppf_effective_date check (no cron, no write-back — promotion is
      W3's job). `buildProcessingFee()` gained a booking-scoped existence guard so a
      booking_id can only ever hold ONE processing_fee Charge (this also fixed a latent
      double-stamp: group-booking creation already derives the fee immediately, and a
      later cancel re-running `deriveBookingCharges()` would have stamped a second row).
      New `FinanceLedgerService::ensureProcessingFee(Booking)` wraps buildProcessingFee +
      save and is now called from `BookingCompletionService::ensureEarnings()` — the
      single chokepoint every real completion path shares (AgentsBookingsController
      solo/group-child, BookingController actionTransition's Complete branch AND
      actionCollect's collect-then-complete, GroupBookingService per-child completion) —
      so a NORMAL completed booking finally gets its processing-fee row. Deliberately did
      NOT hook `common\helpers\PaymentEventHandler::onPaymentSuccess` (the nominal
      "online-payment success" common-code entry point): it is dead code today (imported
      but never called anywhere) and — more importantly — never sets `amount_collected`
      on the booking, so deriving a fee there would stamp against a wrong/stale basis and
      lock it in permanently (the guard is idempotent-forever). The real online-payment
      write path is `api/controllers/BookingController::actionPay`, which IS off-limits
      (api/ tier). Net effect: paid-online bookings get their fee at completion time (via
      the chokepoint above) or at cancel time (pre-existing `deriveBookingCharges()` call
      in `BookingController::actionCancel`) — both already reachable paths, so no booking
      that reaches a terminal state is left without a fee. Files: `common/components/
      FinanceLedgerService.php`, `common/services/BookingCompletionService.php`. Verify:
      `php -l` clean; `codecept run unit` — FinanceLedgerServiceTest (28/28) and
      BookingCompletionServiceTest (2/2) green; 6 errors + 15 failures elsewhere in the
      suite are pre-existing/unrelated (OtpVerificationRateLimitTest — `User::phone`
      unknown property, SmsLogTest, one TokenExpirationTest — none touch finance/booking
      completion code).
- [x] W3 admin governance — 2026-07-22. Admin shop form (`backend/views/shop/_form.php`)
      gained a collapsible "Payment Processing Fee override" card: 2 current-rate fields
      (`payment_processing_fee_rate`/`_fixed`, bound via normal `$form->field()` — both are
      already `safe` Shop attributes from W1) + 3 scheduled-change fields (`ppf_pending_rate`
      / `_fixed` / `ppf_effective_date`) + a `ppf_sub_floor_confirm` checkbox + a
      `ppf_change_reason` textarea. The last two are **virtual (non-DB) public properties**
      on `common\models\Shop` — safe/mass-assignable because Yii's `Model::scenarios()`
      derives safe attributes from `rules()`, not from `ActiveRecord::attributes()` (DB
      columns), so ordinary `$form->field()` + `$model->load()` work unmodified. Validation
      (all in `Shop::rules()` next to the W1 rules): hard cap 0–5.00% / 0–1.50 SAR on all 4
      rate/fixed columns (system-enforced ceiling, blocks above); floor 2.5% / 0.50 SAR —
      `validatePpfFloor()` blocks anything in `(0, floor)` unless `ppf_sub_floor_confirm` is
      ticked; `ppf_effective_date` required-when-pending-set + `validatePpfEffectiveDateNotice()`
      enforces the 7-day minimum notice; `validatePpfChangeReasonRequired()` requires
      `ppf_change_reason` whenever any of `Shop::ppfAuditFields()` differs from its DB value
      (skipped for brand-new shops). A shared numeric-aware `ppfFieldChanged()` helper avoids
      false-positive "changed" detection between DB decimal strings ("2.50") and raw form
      input ("2.5"). `CommercialConfig` got the mirror treatment for the 2 global fields
      (`processing_fee_pct`/`fixed_fee_sar`): hard-cap rules, a `ppf_change_reason` virtual
      property + textarea in `backend/views/commercial-config/index.php`, and the same
      required-when-changed validator.
      **Audit log + email**: `ShopController::actionUpdate` now diffs the 5 PPF fields
      right after `load()` (before `saveAll()` — `afterSave()` resyncs `oldAttributes`, so
      the diff must run first) via `diffPpfFields()`; on successful save,
      `logAndNotifyPpfChange()` writes one `CommercialRateLog::record()` row per changed
      field (`scope='shop'`, `sub_floor_override` = the confirm checkbox, only set true for
      the rate/fixed fields it actually gates) and soft-guarded (`try/catch`)
      `sendPpfChangeEmail()` — via `EmailHelper::instance()->SendToShopOwner()`, the same
      helper `actionRejectRequest` already uses — states the new rate as "effective
      immediately" or, when a pending change exists, "effective {date}".
      `CommercialConfigController::actionIndex` gets the same diff-before-save /
      log-after-save treatment for `scope='global'` (`shop_id=null`) — no email, per spec
      (global changes don't notify shops; per-shop agreements are notified individually).
      **Promotion**: `Shop::promotePpfPendingIfDue()` (pure, unsaved mutation) + the
      controller's `promotePpfIfDue()` wrapper (persists + logs with
      reason `'Scheduled change effective'`) run at the top of `actionUpdate`, before the
      form is populated — idempotent, no cron. Files: `common/models/Shop.php`,
      `common/models/CommercialConfig.php`, `backend/controllers/ShopController.php`,
      `backend/controllers/CommercialConfigController.php`, `backend/views/shop/_form.php`,
      `backend/views/commercial-config/index.php`. Verify: `php -l` clean on all 6; browser
      smoke — `GET /shop/update?id=1` and `GET /commercial-config/index` both HTTP 200 with
      the new sections present and zero exception/warning markers in the response body.
      22 new `Yii::t('backend', …)` keys added — ar/en translations NOT added here
      (single-writer rule on `common/messages/*`); orchestrator to add them.
- [x] W4 shop portal surfaces — 2026-07-22. **Commercials**: `SettingsController::actionIndex`
      resolves `ppfUpcoming` (rate/fixed/effectiveDate) whenever
      `shop.ppf_pending_rate/_fixed` is set AND `ppf_effective_date` is strictly in the
      future (the ledger's `processingRatePct/Fixed` already fold an *arrived* pending
      value into "current" — "upcoming" only ever means still-future); rendered as an
      amber notice line in `_commercials.php`. **Earnings**: `EarningsController` batch-
      fetches the page's stamped `TYPE_PROCESSING_FEE` Charges (`indexBy('booking_id')`,
      one query per page, not per row) and threads `processingFee` (raw stamped
      `total_amount`, never recomputed) into `buildEarningsRows()`; `_earnings.php` shows
      a new "Payment Processing Fee" column between Booking Value and Net Earnings,
      dash when none. **Settlement Request** (`agents-wallet/create` → `_form.php`):
      two new private helpers on `AgentsWalletController` —
      `unpaidProcessingFeesByBooking()` (batched, `status=unpaid` only — a charge already
      paid/settled must not be deducted twice) and `settlementRowBreakdown()` (per-
      ShopEarning-row math, preserves the pre-existing tip-vs-booking-row dedup/skip
      guard verbatim) — shared by `actionCreate()` and the new `actionPdf()` so the two
      surfaces can never diverge. Net formula is now
      `net = final_collected + tips − marketing fees − payment processing fees − VAT`
      (VAT = marketing VAT + the processing charge's own stamped VAT), matching
      `earnings/_settlement.php` exactly; a "Total Payment Processing Fees" deduction
      card was added to the equation-card header and a "Payment Processing Fee" column
      to the per-row table, with the VAT/Net cells now reading the same breakdown so
      rows and totals reconcile. **PDF**: new `actionPdf($id)` (shop-scoped via the
      existing `findModel`) follows the only prior Mpdf pattern in the repo
      (`backend\controllers\PaymentController::actionGeneratePdf`) — groups the
      withdrawal's `shop_earning` rows by `booking_id` (tips folded into their booking's
      line) and renders a plain-HTML/inline-style `_pdf.php` (shop header, per-booking
      table, totals, net formula) via `Mpdf\Mpdf`, forced download. A "Download PDF"
      button was added to `agents-wallet/view.php` (the settlement/transfer-request
      detail page); `actionView()` now passes `withdrawalId` to the view for the link.
      Files: `frontend/controllers/{SettingsController,EarningsController,
      AgentsWalletController}.php`, `frontend/views/settings/{_commercials,index}.php`,
      `frontend/views/earnings/_earnings.php`, `frontend/views/agents-wallet/
      {_form,create,view,_pdf(new)}.php`. Verify: `php -l` clean on all touched/new
      files; browser-verified logged in as the dev test shop owner
      (`Beautycenter123@123.com`) — `/settings/index`, `/earnings/index`,
      `/agents-wallet/create`, `/agents-wallet/view?id=27` all HTTP 200 with the new
      elements present and zero PHP error/warning/notice markers in the response body;
      `/agents-wallet/pdf?id=27` returns a valid 1-page `application/pdf` whose extracted
      text shows the per-booking table + totals + formula line rendering correctly (all
      Payment-Processing-Fee values are 0.00 in this dev DB because no `processing_fee`
      Charge rows exist yet for this shop's bookings — pre-dates the W2 fee-engine wiring
      — not a bug in this wave's code). 6 new `Yii::t('frontend', …)` UI keys (portal) +
      ~17 new `Yii::t('frontend', …)` PDF-template keys — ar/en translations NOT added
      here (single-writer rule on `common/messages/*`); orchestrator to add them.
- [x] W5 refund absorption — 2026-07-22. **Capture**: `frontend\controllers\BookingController::actionCancel`
      now stamps `booking.refund_reason` from the same customer/shop selector that
      already drives refund-zone resolution (`REFUND_REASON_SHOP_CANCELLATION` /
      `_CUSTOMER_CANCELLATION`). `common\helpers\BookingHelper::customerCancelBooking`
      (the customer-initiated path shared with `api\controllers\BookingController::
      actionUpdateStatus` — only the COMMON helper touched, per the api/ off-limits
      rule) now stamps `REFUND_REASON_CUSTOMER_CANCELLATION` on both the booking and
      the `CancellationsAndRefunds` row it builds in `createCancellationRefundRecord`.
      No-show: neither no-show transition path (`BookingController::actionTransition`,
      `AgentsBookingsController::actionUpdateStatus`) creates a refund/cancellation
      record, so both just stamp `booking.refund_reason = REFUND_REASON_NO_SHOW` for
      reporting. **Admin dropdown**: `backend/views/cancellations-and-refunds/_form.php`
      — the `refund_reason` field was already bound to the real (W1) enum column but
      rendered as a free-text textarea; converted to a `Select2` dropdown with the 5
      enum values (admin can pick `navagoo_goodwill` / `platform_issue`). While
      touching this file: it turned out to be **completely broken pre-existing** —
      `GET /cancellations-and-refunds/update` fataled (`UnknownPropertyException:
      booking_id`) because ~9 fields were bound to columns that don't exist on the
      table at all (`booking_id`, `refund_type`, `discount_value`, `promo_discount`,
      `refund_date`, `gateway_refund_id`, `gateway_response`, `notes`, `metadata` —
      stale gii-scaffold vs. the real schema; `STATUS_COMPLETED` / `REFUND_METHOD_CREDIT`
      constants referenced don't exist either). Fixed all of them in the same pass
      (remapped to the real columns: `related_id`, `refund_gateway_transaction_id`,
      `refund_gateway_response`, `admin_notes`, `customer_notes`, `REFUND_METHOD_WALLET`,
      `STATUS_PROCESSED`/`STATUS_CANCELLED`; dropped the dead `refund_type` /
      `discount_value` / `promo_discount` / `refund_date` fields with no real
      equivalent) — otherwise the page could never render to prove the new dropdown
      works. **Absorption**: `FinanceLedgerService::buildProcessingFeeReversal(Charge
      $original, string $refundReason): ?Charge` (additive, mirrors `buildReversal()`'s
      shape) returns a full negative reversal row (`reversal_of_id`, `-total_amount`,
      `-vat_amount`, same type/rate/fixed snapshot) only when `$refundReason` is
      `navagoo_goodwill` or `platform_issue`; docblock on `buildProcessingFee()`
      updated to point at it instead of asserting the fee never reverses. **Wired**
      in `CancellationsAndRefundsController::actionUpdate` (the only place refund
      reason is admin-set — no separate "process" action exists) via new private
      `maybeReverseProcessingFee()`: after a successful save, when `refund_reason`
      is one of the two Navagoo reasons and the record is booking-linked
      (`related_type = RELATED_TYPE_BOOKING`), finds that booking's original
      (non-reversal) `TYPE_PROCESSING_FEE` charge and reverses it — idempotent via
      an explicit `reversal_of_id` existence check (skips if already reversed),
      soft-guarded (`try/catch` + `Yii::warning`) so a finance hiccup never blocks
      the admin's save. Files: `frontend/controllers/{BookingController,
      AgentsBookingsController}.php`, `common/helpers/BookingHelper.php`,
      `common/components/FinanceLedgerService.php`,
      `backend/controllers/CancellationsAndRefundsController.php`,
      `backend/views/cancellations-and-refunds/_form.php`. Verify: `php -l` clean on
      all 6; `codecept run unit` — FinanceLedgerServiceTest (33/33) and
      BookingCompletionServiceTest (2/2) green, full suite unchanged at 261
      tests/6 errors/15 failures (same pre-existing baseline as W2, none touch
      finance/booking/cancellations code); browser/curl smoke —
      `GET /cancellations-and-refunds/update?id=55` now HTTP 200 (was a pre-existing
      500) with the `refund_reason` Select2 rendering all 5 options including
      `navagoo_goodwill` / `platform_issue`. 5 new `Yii::t('backend', …)` keys —
      ar/en translations NOT added here (single-writer rule on `common/messages/*`);
      orchestrator to add them: "Customer Cancellation", "Shop Cancellation",
      "Navagoo Goodwill (fee absorbed)", "Platform Issue (fee absorbed)",
      "Select Refund Reason...". (Reused pre-existing keys for the other new dropdown
      options — "No Show", "Processed", "Cancelled", "Account Credit" already had
      translations from elsewhere in the file.)
- [x] W6 tests + verify — 2026-07-22. **Unit tests**: new
      `common/tests/unit/components/PaymentProcessingFeeTest.php` (13 tests / 66
      assertions, all green) covering the BRD acceptance criteria — AC-1 (SAR 8.00 on
      Final Collected 200 at 3.5% + SAR 1, ex-VAT, VAT-on-top asserted against the live
      rate per the FinanceLedgerServiceTest convention), stamped forward-only + the
      one-fee-per-booking idempotency guard (the ONE DB-backed test: saves the first
      charge inside a transaction that is ALWAYS rolled back — charge has no FK
      constraints so a synthetic booking_id is safe — flips the rate source to 4%, and
      asserts buildProcessingFee() returns null while the stored row keeps 3.5+1.00),
      per-shop override beats global resolution, pending-rate gating (future
      ppf_effective_date → current rate; arrived → pending wins), Shop-model hard-cap
      validation (5.5% / SAR 1.60 rejected; 4.0 / 1.20 accepted), sub-floor gate (2.0%
      blocked without ppf_sub_floor_confirm, allowed with), 7-day-notice rule (today+3
      rejected, today+7 accepted), W5 absorption (customer/shop cancellation + no_show
      → null; navagoo_goodwill + platform_issue → full negative reversal re-stamping
      the ORIGINAL snapshots with reversal_of_id set; wrong charge type refused), and
      pay-on-visit (collected 0, incl. the DB's "0.00" decimal-string shape → no fee
      row). Style matches FinanceLedgerServiceTest exactly (in-memory unsaved AR
      models, environment-independent money assertions via pinned per-shop overrides,
      conditional assertions where CommercialConfig presence varies). Full suite:
      274 tests / 622 assertions — FinanceLedgerServiceTest 33/33,
      BookingCompletionServiceTest 2/2, PaymentProcessingFeeTest 13/13 all green;
      6 errors + 15 failures are the identical pre-existing baseline from W2/W5
      (OtpVerificationRateLimitTest ×6 errors; AuroraTest ×5, CalendarFormatTest ×4,
      SmsLogTest ×5, TokenExpirationTest ×1 failures — none touch finance code);
      charge table verified empty after the run (rollback held). **Smoke** (curl):
      admin (authed) — /commercial-config/index 200 (ppf_change_reason field present),
      /shop/update?id=1 200 (PPF override card: payment_processing_fee_rate,
      ppf_pending_rate, ppf_sub_floor_confirm all rendered), /cancellations-and-
      refunds/update?id=55 200 (refund_reason enum dropdown incl. navagoo_goodwill +
      platform_issue), /admin-finance/transfer-requests 200 — zero
      Exception/Fatal/Undefined markers in any body. Shop portal — /settings/index,
      /earnings/index, /agents-wallet/create all 302 → sign-in/login (guest curl, as
      expected; W4's agent already browser-verified all three logged in as the dev
      test shop owner, HTTP 200 + new PPF elements present). No bugs found → no code
      changes this wave (tests + docs only). NVG-BEA-002 COMPLETE (W1–W6); deferred
      items unchanged: BNPL rates, customer-facing display, Paymob auto-reconciliation,
      OQ-4/OQ-5 (owner: Muhannad); note the api/ actionPay stamping deviation recorded
      in W2 (online-payment fee stamps at completion/cancel, not at the api pay
      callback, since api/ is off-limits).
