# Demo sync — Milestone N (dynamic notifications v0.13.0) phase-2 port plan

**Date:** 2026-07-04 · **Branch:** `tailwind-poc` · **Demo range analyzed:** `5cbd4b3..cb4712b` (v0.12.0 → v0.13.0)
**Status:** ✅ COMPLETE (2026-07-04) — all phases landed, portal commits `c46e6e3` (Phase 1),
`3d5dbe2` (Phase 2), `820b961` (Phase 3), `5bd6ede` (agent-slots fix found during verification),
`bb01f13` (i18n+css). Verified: 29/29 render-smoke green, 17 engine unit tests green,
fire→bill→fail→refund→idempotent exercised live. Review: 0 critical/high; both mediums fixed
(reversal-status convention aligned; cron withoutOverlapping). Follow-ups tracked in the
playbook §7 (2026-07-04 entry): settlement_received dispatch, group-booking call sites,
real Msegat/T2 delivery + DLR.

## Context

Milestone N landed in the demo (`../Navagoo_MI`) on 2026-06-27/28 (~30 commits). Waves 3/10/11/13/16
(see `HANDOVER.md`) already ported roughly half. This plan disposes **every remaining commit** and
groups the real delta into 3 implementation phases + 1 docs phase.

Ground rules honoured throughout (see CLAUDE.md + playbook):
- `notifications` is a **SHARED table** (mobile API reads it) → **no schema change there**. Portal-only
  linkage (charge ↔ notification) lives in `charge.meta` JSON instead.
- `commercial_config`, `charge`, `notification_trigger`, `shop_notification_setting` are portal-only → safe to migrate.
- Bilingual `Yii::t()` for every new string (en + ar). `npm run build:css` after any class change.
- Real DB/endpoints only; provider webhooks (Msegat DLR / T2 push) are **deferred** per the demo's
  own integration handoff spec (commits `c79f6b7`/`2ba6594`/`62a31db`) — captured as follow-up.

## Commit-by-commit disposition

| # | Demo commit | What it is | Disposition |
|---|---|---|---|
| 1 | `9c109de` fix(N): scope rederiveCharges | Demo-store bug: status transitions wiped notif/subscription charge rows | **N/A** — portal ledger is append-only (`charge` table); nothing rederives. Verified by design. |
| 2 | `0e33c4e` types + config fields | TS types/EVT unions | **N/A** (demo-internal typing) |
| 3 | `bee93ba` pure helpers `resolveChannel`/`notifDueAt` | Channel resolution + due-instant math | **Phase 2** — `notifDueAt` semantics into the reminder cron; `resolveChannels` already ported in `NotificationDispatchService` |
| 4 | `5c957dd` notifFailRefundDraft | Admin-configured per-message failure refund (SAR, capped at original) | **Phase 1 (schema+admin UI) + Phase 2 (calc)** — new `commercial_config.sms_fail_refund`/`wa_fail_refund` |
| 5 | `7def7b6` trigger CRUD + per-shop settings | Store actions | **DONE** (waves 13/16) |
| 6 | `f940b0e` firing engine + free-allowance gate | fireNotifications / processDueNotifications / notifySettlement + billed-over-allowance | **Phase 2** — charge-row creation on over-allowance send; wire event call-sites; reminder cron |
| 7 | `76f68b3` markNotifFailed | Failure → reversal charge by configured refund | **Phase 2** — service method + reversal row (`reversal_of_id`); DLR webhook deferred |
| 8 | `dbb4e66` seeds + persist | Seed triggers | **DONE** (migration `m260628_120000`) |
| 9 | `c43d9d9` admin trigger catalogue UI | Admin CRUD screens | **DONE** (wave 13) |
| 10 | `f196c36` admin commercial config messaging | Messaging section | **PARTIAL → Phase 1** — restructure into per-channel cards + margin badge + refund + free/month fields |
| 11 | `1cb2eba` settings tab wiring | Shop tab → store | **DONE** (wave 16 + hardening) |
| 12 | `2d815c5` shop inbox — bell dropdown + page | Bell dropdown (8 recent, mark-read) + inbox page | **PARTIAL → Phase 3** — page exists; dropdown + per-row mark-read missing |
| 13 | `d694be8` + `56f1a62` notif fees in settlement | Earnings drawer + payout netting | **DONE** (wave 3) — Phase 1 adds the per-channel split from `2a3a90d` |
| 14 | `7d5545f` i18n event templates | Demo i18n | **N/A** (covered by bilingual template columns) |
| 15 | `5b4fe9e` + `0f1a459` per-channel templates + approval + admin table | Template authoring/approval | **DONE** (wave 16, migration `m260628_220000`) |
| 16 | `e05192c` picker gated to approved channels | Approval gating in shop UI | **DONE** (wave 16) |
| 17 | `81ce73d` per-booking sent-notifs bell | Booking detail section | **DONE** (06-28 wave 2) |
| 18 | `b62a26d` compare due as instants | String-vs-instant compare bug | **Phase 2** — cron compares epoch timestamps, never strings |
| 19 | `b27bcf6` timing modes | offset vs days_before+at_time | **DONE** (migration `m260628_130000`) — consumed by the Phase-2 cron |
| 20 | `f40a7e6` multi-channel toggles | channels[] array model | **SUPERSEDED** by `7e1f4d4`/`41da2ef` (single paid channel) — portal already on the final model; skip |
| 21 | `2a3a90d` UX-review fixes P1+P2 | 11 concrete fixes (see below) | **Phase 1** |
| 22 | `c1348b3` widen tab | `max-w-2xl` → `max-w-4xl` + overflow-x-auto | **Phase 1** |
| 23 | `41da2ef` enforcement model | Free in-app baseline + one paid channel; same-day guard | **MOSTLY DONE** (dispatch already enforces) → same-day guard (`due >= booking created`) lands in **Phase 2** cron |
| 24 | `7e1f4d4` simplify model | in-app = admin-only; shop picks paid channel | **DONE** (current `_notifications.php` + dispatch) — Phase 1 verifies "Platform-managed" copy on non-optional rows |
| 25 | `9c04ab4` + `14ba5ce` proper table | Fixed-header table, capped 1st col, equal 3-up buttons, preview expand | **Phase 1** |
| 26 | `455c946` + `cb4712b` release chores | — | **N/A** |
| 27 | `c79f6b7`/`2ba6594`/`62a31db` docs(②) | Msegat (poll-DLR) + T2 (push-DLR) + Paymob integration handoff spec | **Follow-up item** (documented, not implemented) — real send + DLR webhooks |

## Portal baseline (audited 2026-07-04)

- `NotificationDispatchService` already: resolves final-model channels, gates on approval + template,
  counts monthly usage (`module` = `notif_sms`/`notif_whatsapp`), idempotency guard. **Does NOT**: create
  charge rows, send via provider, handle failure/refund.
- Free limits: constants in `ShopNotificationSetting` (SMS 50/mo · WA 20/mo · 0.25/0.10 SAR) — demo
  moves these knobs into admin commercial config.
- No cron consumes `timing_mode`/`days_before`/`at_time` (legacy `bookings/reminder-24h|3h` is the old
  Firebase path). `charge` ledger + `CommercialConfig` exist. Navbar bell = link + badge only.
- Msegat (`common/helpers/SMSHelper`) + T2 (`common/helpers/WhatsAppHelper`) helpers exist but are NOT
  wired to dispatch (correct for now — delivery deferred to the integration follow-up).

## Phases

### Phase 1 — UI parity: settings table, admin polish, commercial config (commits 9c04ab4, 14ba5ce, c1348b3, 2a3a90d, 5c957dd-schema, f196c36-completion)
1. **Migration** `commercial_config` + : `sms_fail_refund`, `wa_fail_refund`, `sms_free_month`, `wa_free_month`
   (DECIMAL/INT, nullable → fall back to `ShopNotificationSetting` constants). Portal-only table; no API impact.
2. **Shop settings notifications tab** (`settings/_notifications.php`): single fixed-header `<table>`
   (`table-fixed` + `<colgroup>`) — columns *Notification | In-app (auto) | Paid channel*; capped first
   column with truncated template preview + expand/collapse chevron; per-trigger schedule label + "N sent
   this month"; non-optional rows show centered *Platform-managed*; 3-up equal buttons (None/SMS/WhatsApp,
   h-11, fee on 2nd line); `ngConfirm` cost dialog before selecting a paid channel; container `max-w-4xl`
   + `overflow-x-auto`.
3. **Admin trigger form**: live rendered template preview + SMS segment counter ("X chars · Y segments");
   `ngConfirm` warning when editing an approved template (re-pends approval; shops lose the channel).
4. **Admin commercial config**: messaging section → two per-channel cards (sell / cost / refund-on-fail /
   free-per-month) + derived margin badge (green ≥ 0 / red < 0).
5. **Finance surfaces**: earnings drawer splits "Notifications" into per-channel rows (SMS / WhatsApp);
   charge type labels → "SMS notification"/"WhatsApp notification"; charges list annotates reversal rows
   ("reverses CHG-x · failed delivery").
6. i18n en+ar · `php -l` · `npm run build:css` · browser-verify (dev test login) · commit.

### Phase 2 — Engine completion: charges, refunds, reminder cron (commits f940b0e, bee93ba, b62a26d, 76f68b3, 5c957dd-calc, 41da2ef-guard)
1. **Charge creation in dispatch**: paid customer send → count usage; over free limit (CommercialConfig
   override → constants fallback) → append `charge` row (`type` sms|whatsapp, `total_amount` = sell price,
   `settlement_method` net_from_settlement, `status` unpaid, `booking_id`, `meta.notification_id`).
2. **`markNotifFailed($notificationId)`**: locate the linked charge via `meta.notification_id`; refund =
   `min(per-msg refund × count, original)`; if > 0 append negative reversal row (`reversal_of_id`), flip
   original `status` → reversed; zero-refund → no row. `meta` records `failed delivery`.
3. **Reminder cron** `console/controllers/NotificationsController::actionProcessDue` + schedule entry
   (*/5 min): active time-based triggers × non-cancelled future bookings → due instant per timing mode
   (offset: appointment − minutes; calendar: appointment date − days_before @ at_time) — compared as
   **epoch timestamps** (b62a26d) with the same-day guard `due >= booking.created_at` (41da2ef). Fires
   through the existing idempotent dispatch.
4. **Event call-site audit**: confirm dispatch fires on booking-confirmed / cancelled / payment-collected /
   settlement; wire any missing site (no API-tier changes).
5. Unit tests (Codeception) for refund math + due-instant math · security review (finance code) · commit.

### Phase 3 — Shop inbox bell dropdown + verification sweep (commit 2d815c5 remainder)
1. Navbar bell → dropdown: 8 most recent, unread dot, "Mark all read", "View all" → `/notifications`;
   click marks read (`ngPost` AJAX → existing controller + new `mark-read` action).
2. Notifications page: per-row click-to-mark-read.
3. Full sweep: i18n scan to 0 missing · `php -l` all touched · `npm run build:css` · render + interaction
   verify (playbook GOTCHA: "renders ≠ works") · commit.

### Phase 4 — Documentation
Playbook §7 PORT STATUS + HANDOVER + `DEMO_PARITY_GAP_BACKLOG.md` updated; Msegat/T2/Paymob real-delivery
integration recorded as the follow-up item with a pointer to the demo spec commits.

## Follow-ups (explicitly out of scope here)
- ~~`settlement_received` dispatch + group-booking call sites~~ **CLOSED 2026-07-05** (`fe846bb`,
  engine fixes `7d2a2e0`) — see the backlog 2026-07-05 entry.
- **Real SMS/WA delivery + DLR**: wire `SMSHelper`/`WhatsAppHelper` into dispatch behind a feature flag;
  Msegat poll-based DLR (`getMessages.php` by `reqBulkId`), T2 push webhook; per the demo integration spec.
- Paymob webhook hardening per the same spec.
- Customer-audience in-app rows → mobile feed (needs sign-off; doubles legacy pushes today).
