# NVG-BEA-003 — Subscription Billing & Plan Management — Gap Analysis + Implementation Plan

**Status: COMPLETE W1–W6 — started + finished 2026-07-22.** Live tracker at the bottom.
Spec: NVG-BEA-003 BRD (user-provided). Prereq facts audited 2026-07-22 (agent, file:line
evidence). NOT to be confused with `subscription_package` (shop→customer packages).

## Verdict: ~60% built; the recurring engine is the missing heart

### Already DONE
| BRD item | Where |
|---|---|
| Plan catalogue: monthly/6m/12m prices + discount %s, free_period_days, features JSON, auto_deactivate_on_expiry, status, tier | `navagoo_subscription_plan` (m260624_130100 + m260706_150000) + admin `shop/plans` CRUD |
| shop_subscription schema: period, next_billing_at, free_period_ends_at, status enum (free_period/active/past_due/cancelled/expired/flagged), term stamping, pending_plan_id, trial_consumed, card_last4 | `common/models/ShopSubscription` |
| One-time free period (resubscribe doesn't reset) | `NavagooPlansController::actionSubscribe` (trial_consumed) |
| Shop plans page (tiers/offers/upgrade-downgrade/cancel) | `/navagoo-plans` |
| Bank-transfer rail (slip upload → admin invoice verify) | NavagooPlansController + AdminInvoiceController::actionVerify |
| Admin Subscription Dashboard + MRR KPIs + assign/activate/cancel/flag | `shop/subscriptions` + SubscriptionMetricsService |
| No-plan banner (Home only) | SiteController::buildHomeSubscriptionBanner |
| Settings→Commercials subscription line (plan/status/next billing) | `settings/_commercials.php` |

### GAPS
1. **No recurring billing engine at all** — nothing fires at `next_billing_at`; subs sit
   unbilled after the term. No renewal charge, no free→active transition charge, no term
   advance, no expiry lapse.
2. **No renewal reminder email** (3 days before) and no billing-confirmation email.
3. **No dunning** — no retry (3 attempts / 7 days), no auto-flag, no shop notification
   with retry link. Admin "flag" is a manual button.
4. **Card rail is simulated** — `EarningsController::actionAddCard` fakes tokens;
   `recordSubscriptionCharge` marks card charges PAID without calling Paymob. No
   saved-token/recurring charge call in PaymobPaymentHelper. No default-card designation.
5. **Entitlements built but never enforced** — `EntitlementService` (hasFeature/canAccess)
   has zero call sites; plan features are display-only. No locked/upgrade prompt.
6. **Cancellation "active until term end" is UI copy only** — status flips to cancelled
   immediately and the (unwired) access state would lock it.
7. **Banner is Home-only**; BRD wants portal-wide steer + plan selection required before
   going live (no gating exists).
8. Admin dashboard lacks free_period_expiry / last_billing / payment_status columns;
   past_due/expired render as "None" badge.
9. `navagoo_subscription_plan.description` column missing. Billing-period change
   next-cycle needs a `pending_period` (only pending_plan_id exists).

### API-impact note
All additive (columns + new console command + frontend/backend surfaces). No api/ contract
change. Paymob recurring uses saved-token charge; falls back to the existing simulated rail
when Paymob creds are absent (dev).

## Waves
- **W1 Schema (additive):** `navagoo_subscription_plan.description` TEXT;
  `shop_subscription`: `pending_period` VARCHAR(16), `failed_attempts` TINYINT DEFAULT 0,
  `first_failed_at` INT, `last_billing_at` INT; card store: default-card flag on the shop
  card table (locate actual table; add `is_default` + `gateway_token` if missing).
- **W2 Payment rail + Payment Cards screen:** PaymobSubscriptionHelper (auth → saved-token
  /MOTO charge) used when Paymob creds configured, else existing simulated rail (dev).
  Shop Finance → Payment Cards: list/add/delete/set-default (real tokenization when
  configured). Default card funds renewals.
- **W3 Billing engine (console):** `subscription-billing` command in schedule.php: (a) T-3d
  renewal reminder email; (b) due renewals → charge default card via rail → advance term
  (period pricing incl. pending_period/pending_plan_id switch), billing confirmation
  email; free_period end → first charge; (c) failure → notification + retry link, retry
  window, 3 fails/7 days → status flagged (admin review; no auto-deactivate); (d) lapse:
  cancelled subs past current_term_end → expired; auto_deactivate_on_expiry plans →
  deactivate shop on expiry.
- **W4 Entitlements + gating:** wire EntitlementService as a shop-portal filter (controller
  →feature map) + locked page w/ upgrade prompt; cancelled keeps access until
  current_term_end; portal-wide no-plan banner (layout); block go-live/ops surfaces when no
  subscription ever selected.
- **W5 Admin polish:** plans form description field; dashboard columns (free-period expiry,
  last billing, payment status), proper past_due/expired badges, dunning visibility
  (failed_attempts).
- **W6 Translations + tests + smoke + docs.**

### Sequencing
W1 → W2 → W3 (engine consumes rail) with W4 ∥ W5 alongside W2/W3 (disjoint files) → W6.

### Deferred (per BRD)
Proration · hard-coded tiers · auto-deactivate on failed payment (manual, unless per-plan
toggle) · OQ: same card for general vs subscription billing (Muhannad).

## Live tracker
- [x] W0 audit + this plan
- [x] W1 schema (m260722_140000 applied 2026-07-22: plan.description, shop_subscription pending_period/failed_attempts/first_failed_at/last_billing_at, user_card is_default/gateway_token/gateway)
- [x] W2 payment rail + cards screen — DONE 2026-07-22. `common/helpers/PaymobSubscriptionHelper.php`
  (new): `isConfigured()`/`isTokenizationConfigured()` env guards (never opens a socket when unset),
  `chargeSavedCard()` (auth→order→payment key→pay-with-token MOTO charge), `startCardTokenization()`
  (auth→order→payment key→hosted iframe URL). Card tokenization reuses the EXISTING generic
  `api/controllers/WebhookController::actionPaymob()` TOKEN handler UNEDITED — a tokenization order
  has no matching `payment` row, so Paymob's callback falls into that handler's pre-existing "queue to
  `paymob_temp_token`" branch, claimed by the new `EarningsController::actionCardTokenStatus()`
  (frontend polls it every 3s while the iframe modal is open). `UserCard::defaultForUser()` +
  `GATEWAY_PAYMOB` const added; `setDefault()` wired into add/delete/set-default. `EarningsController`:
  `actionAddCard` (dev/simulated rail, unchanged behaviour, now auto-defaults the first card added),
  new `actionCardTokenStart`/`actionCardTokenStatus` (real rail), new `actionDeleteCard` (hard delete —
  `user_card` has no soft-delete column; promotes the next card to default), `actionSetDefaultCard`
  (now uses `UserCard::setDefault()` instead of the old updated_at-bump workaround).
  `NavagooPlansController::recordSubscriptionCharge` now returns bool: card rail attempts a real
  `chargeSavedCard()` when Paymob is configured AND the resolved default card has a `gateway_token`,
  else keeps the pre-existing simulated instant-PAID rail (dev); on decline NO Charge row is written
  and the caller (`actionSubscribe`/`actionUpgrade`) surfaces an error instead of activating/renewing.
  Payment Cards UI stays inline on `/navagoo-plans` (existing panel — matches current IA, Finance's
  Subscription tab already links out to this page; no new route) with a delete button added and the
  Add-card button branching to the hosted-iframe rail when `paymobTokenizationConfigured`. New env keys
  (blank by default, additive): `PAYMOB_MOTO_INTEGRATION_ID`, `PAYMOB_CARD_INTEGRATION_ID`,
  `PAYMOB_IFRAME_ID` (`.env.dist`). No `api/` edits; no migrations; 12 new `Yii::t('frontend', …)` keys
  (not yet added to `common/messages/*` — tracked for W6). php -l clean on all touched files; curl smoke
  (authed shop-owner session) `/navagoo-plans/index` and `/earnings/index` → HTTP 200, 0 error markers.
- [x] W3 billing engine — DONE 2026-07-22. New `console/controllers/SubscriptionBillingController.php`
  (`subscription-billing/{remind,charge,lapse,run}`, `--dry=1` on every action — no DB writes/mail/
  gateway calls in dry mode). Mirrors `NavagooPlansController::recordSubscriptionCharge`/
  `issueSubscriptionInvoice` locally (those are `private` on a web controller — mirrored, not shared,
  per the wave's hard rules). **remind**: status=active subs with `next_billing_at` in the half-open
  window [now+2d, now+3d) get a T-3d email — no `reminder_sent_at` column exists (W1 didn't add one),
  so the window itself is the de-dupe (documented tradeoff in the docblock: a skipped cron run can miss
  the window, a same-day re-run can re-send). **charge**: free_period + active subs with
  `next_billing_at <= now`; resolves `pending_plan_id`/`pending_period` at this exact boundary (swaps
  in, clears pending fields) via `NavagooSubscriptionPlan::priceForPeriod()`; card rail reuses
  `PaymobSubscriptionHelper::chargeSavedCard()` when configured + the default `UserCard` carries a real
  token, else the existing simulated dev-PAID rail; bank rail raises an Invoice (trigger=subscription,
  UNPAID, `document_status=DOC_NONE` — no slip yet, picked up by the existing
  `EarningsController::actionPayInvoice()` / `AdminInvoiceController::actionVerify()` flows) guarded by
  the same "one unresolved invoice per shop" check as `actionSubscribe`. Success → `ShopSubscription::
  stampTerm()` (reused model method) advances current_term_start/end + next_billing_at, status=active,
  `last_billing_at` stamped, failed_attempts/first_failed_at reset, billing-confirmation email. Bank
  invoice raised → term also advances optimistically (matches `actionSubscribe`'s existing behaviour),
  status=past_due until Navagoo verifies. Card decline → dunning state machine (failed_attempts++,
  first_failed_at anchors a rolling 7-day window; ≥3 attempts inside the window → status=flagged for
  admin review; window elapsed with <3 attempts → counter resets fresh) + a payment-failed email linking
  to `/navagoo-plans`; **no term advance and no auto-deactivate on decline** (past_due/flagged subs are
  excluded from the next run's WHERE, so they wait on the shop/admin, not the cron). Double-charge guard:
  every card-rail PAID charge is tagged `meta.term_key = "sub{id}-{dueAt}"` (written via `JsonExpression`
  so `JSON_EXTRACT` stays queryable — see NotificationDispatchService precedent); `actionCharge` checks
  for an existing PAID charge with that term_key before attempting a charge. **lapse**: (a) cancelled
  subs past `current_term_end` → expired; (b) ALL currently-expired subs on an
  `auto_deactivate_on_expiry=1` plan → shop `status = STATUS_NOT_ACTIVE` (mirrors
  `ShopController::actionToggleActive`'s minimal field write), idempotent (skips already-inactive
  shops), `Yii::info(..., 'audit')` logged; (c) defensive backstop — free_period subs with
  `free_period_ends_at` elapsed but `next_billing_at` still null get it backfilled (should rarely fire;
  `actionSubscribe` already sets `next_billing_at = free_period_ends_at` at trial start).
  Deliberately does NOT lapse past_due/flagged subs (BRD: no auto-deactivate on failed payment, admin
  review only). `console/config/schedule.php`: re-read fresh immediately before editing (freeze-reminder
  entry from a concurrent session was already present, untouched) — appended
  `subscription-billing/run` `->daily()->withoutOverlapping()` to both the qc and prod blocks. New
  `Yii::t('frontend', …)` keys (6, not yet in `common/messages/*` — tracked for W6, same as W2/W4):
  "Your Navagoo {plan} plan renews in 3 days", "Dear {owner}, your Navagoo subscription ({plan},
  {period}) renews on {date} for {amount} SAR. No action is needed if your payment method is up to
  date. Manage your plan at /navagoo-plans.", "Your Navagoo {plan} subscription was renewed", "Dear
  {owner}, your Navagoo subscription ({plan}, {period}) was renewed for {amount} SAR on {date}. Thank
  you for staying with Navagoo.", "We could not renew your Navagoo {plan} subscription", "Dear {owner},
  we tried to charge your card {amount} SAR for your Navagoo subscription ({plan}, {period}) but the
  payment failed (attempt {attempts} of 3). Please update your payment method or retry from your
  Navagoo Plans page: /navagoo-plans." — `php -l` clean on both touched files. Verified against the dev
  DB (not a fixture — the live shared docker DB per project memory): `subscription-billing/run --dry=1`
  completes cleanly with 0 due (both real `shop_subscription` rows are free_period, next billing weeks
  out); temporarily forced sub #1's `next_billing_at` into the past to exercise the real (non-dry)
  `charge` path end-to-end — correctly detected the due free→paid conversion, resolved plan="Growth"/
  period=monthly/amount=225.00, issued a real Invoice+linked UNPAID Charge (bank_transfer rail, meta
  term_key present), advanced the term, flipped status→past_due, and a re-run correctly no-op'd (0 due —
  past_due excluded from the WHERE, proving the double-charge guard's outer effect); all test data
  (invoice, charge, subscription fields) reverted to the exact original snapshot afterward. Targeted
  suite: `codecept run unit --filter 'FinanceLedgerServiceTest|EntitlementServiceTest|
  SubscriptionMetricsServiceTest'` → 46/46 green, 107 assertions, 0 regressions. Full `codecept run unit`
  (274 tests) has 15 failures/6 errors, all pre-existing and unrelated (OtpVerificationRateLimitTest,
  AuroraTest, CalendarFormatTest, SmsLogTest, TokenExpirationTest — none touch Subscription/Charge/
  Invoice/Entitlement/Finance).
- [x] W4 entitlements + gating — DONE 2026-07-22. EntitlementFilter ('as entitlement' in
  frontend/config/web.php — app-level because gated controllers override behaviors()
  without parent merge); controller→feature map (package/shop-analytics/promo-code/
  customer-invitations/social-media/branch) + go-live gate (booking-calendar,
  agents-bookings/create-walk-in need ANY sub row); locked page views/site/_locked.php;
  cancelled = access until current_term_end (+ flagged/past_due = grace) in
  EntitlementService::subscriptionAccessState; shopFeatures() now ignores stamped
  display-label features JSON (falls back to tier set); portal-wide amber no-plan strip
  in layouts/tailwind.php (session-dismissible, aurora-core.js). i18n keys pending W6.
- [x] W5 admin polish — DONE 2026-07-22 (backend-only). Plans form: `description`
  textarea in the create/edit modal (`backend/views/shop/plans.php`), persisted in
  `ShopController::savePlan()`, shown as muted 2-line-clamp text on the plan card
  when set. Subscription Dashboard (`backend/views/shop/subscriptions.php` +
  `ShopController::actionSubscriptions()`): "Next billing" column replaced by a
  stacked "Billing" cell (Next / Free-period ends / Last billed — last billed
  falls back to `current_term_start` when `last_billing_at` is still null); new
  "Payment status" column (Paid/Pending/Failed (Nx)/—) + a red dunning chip
  (retry count, `first_failed_at` date as tooltip) when `failed_attempts > 0`;
  status badge map gained proper `past_due` (amber) and `expired` (slate) rows
  instead of falling through to "None". All classes reused from the already-compiled
  admin bundle (amber-50/600, slate-100/600, red-50/100/600/700) — no `build:css`
  run. php -l clean; curl smoke (backend session) both `/shop/plans` and
  `/shop/subscriptions` → HTTP 200, 0 error markers, new fields/columns present in
  the rendered HTML.
- [x] W6 translations + tests + verify — DONE 2026-07-22.
  **Translations:** grepped the actual working-tree diff (not the plan doc's lists) across
  every uncommitted BEA-003 file (`PaymobSubscriptionHelper`, `SubscriptionBillingController`,
  `EntitlementFilter`, `_locked.php`, `NavagooPlansController`, `EarningsController`,
  `FrontEndController`, `layouts/tailwind.php`, `backend/{ShopController,views/shop/plans,
  views/shop/subscriptions}`) — including multi-line `Yii::t(` calls (email bodies), which a
  naive single-line grep misses. Per the task's instruction, swept the SAME diff for the
  co-resident BEA-005 (attribution/freeze) files too (`CustomersController`,
  `CustomerInvitationsController`, `CustomerClassificationService`, `CustomerFreeze`,
  `UserController`, `backend/views/user/people.php`, `FreezeReminderController`,
  `WalkInBookingService`) — one sweep, both features, since messages/* had a single writer
  this run. Result: **53 new frontend keys + 22 new backend keys**, added to all 4 files
  (`common/messages/{en,ar}/{frontend,backend}.php`) — en=identity, ar=natural financial-
  register Arabic, placeholders (`{plan}`, `{amount}`, `{days}`, etc.) left untouched.
  `php -l` clean on all 4 files. Key-count parity verified programmatically:
  frontend en=1898/ar=1898 (Δ0), backend en=3320/ar=3320 (Δ0), both exactly +53/+22 over the
  pre-sweep HEAD baseline (1845/1845, 3298/3298).
  **Unit tests (39 new/extended, all green):**
  - `common/tests/unit/models/NavagooSubscriptionPlanTest.php` (8 tests) — `priceForPeriod()`
    monthly/six_month/twelve_month incl. unknown-period fallback, `perMonthPrice()`,
    `savePctForPeriod()` incl. zero-monthly-price guard, `isValidPeriod()`.
  - `common/tests/unit/models/ShopSubscriptionTest.php` (14 tests) — `stampTerm()` for all 3
    periods (start/end/price/next_billing_at lock-step + price rounding),
    `addMonthsClamped()` end-of-month/leap-year overflow, `periodMonths()`, `statusLabel()`,
    status predicates, `hasBillingRelationship()`.
  - `common/tests/unit/components/EntitlementServiceTest.php` (+1 test, extending the
    existing Wave-3 file) — `testCancelledSubscriptionKeepsGraceAccessUntilCurrentTermEndThenLocks`:
    the 3 time-boundary branches (`grace` mid-term, `grace` at the exact boundary, `locked`
    once `current_term_end` has elapsed) that the pre-existing
    `testSubscriptionAccessStateMapping` (no `current_term_end` stamped) didn't reach.
  - `common/tests/unit/console/SubscriptionBillingControllerTest.php` (5 tests, NEW dir) —
    the private `applyDunning()` dunning state machine via `ReflectionMethod` against a real
    `new SubscriptionBillingController('subscription-billing', Yii::$app)` (the bootstrap's
    console `Yii::$app` makes this constructible without a full `run` bootstrap): 1st failure
    → `past_due`/attempt 1, 2nd within window → still `past_due`, 3rd within the 7-day window
    → `flagged`, window-elapsed-without-3-attempts → fresh cycle (not a false flag),
    `past_due_since` stamped once and never overwritten.
  - `common/tests/unit/models/UserCardTest.php` (5 tests) — `setDefault()` guard clauses
    (false without a persisted id / without `user_id`) and `defaultForUser()`'s two safe,
    side-effect-free paths (falsy user id short-circuits before any query; an absent user id
    hits a real `find()` that matches 0 rows). Documented scope note: this suite has no Db
    module/fixture rollback (`common/tests/unit.suite.yml` only enables `Asserts`), so the
    actual `updateAll()`+`save()` default-flip WRITE path is intentionally not re-exercised
    here — it was already verified end-to-end against the live dev DB during W2.
  - **Deliberately SKIPPED (per the task's own escape clause) — trial_consumed guard**: the
    one-time-trial ternary (`frontend/controllers/NavagooPlansController.php:278`,
    `$trialDays = ((int) $sub->trial_consumed === 1) ? 0 : (int) $plan->free_period_days;`)
    is inline in `actionSubscribe()`, a large controller action with session/request/DB-save
    side effects — not extracted into a testable unit, and extracting it would be a
    production-code change unjustified by a real bug (hard rule: prod changes only when a
    test exposes one). A "mirror the formula in the test" pin was considered and rejected —
    it would only re-verify PHP's ternary operator, not the actual code path, risking false
    confidence without ever failing on a real regression.
  - Run: `docker exec projects-webserver bash -c "cd /var/www/html/Navagoo && vendor/bin/codecept run unit -c common"`
    (note: `-c common` needed — no top-level `codeception.yml`, config lives at
    `common/codeception.yml`). New/extended tests: **39/39 green, 107 assertions**. Full
    suite: **304 tests, 690 assertions, 6 errors + 15 failures** — byte-for-byte the
    documented pre-existing baseline (`OtpVerificationRateLimitTest` ×6 errors;
    `AuroraTest` ×5, `CalendarFormatTest` ×4, `SmsLogTest` ×5, `TokenExpirationTest` ×1
    failures — none touch Subscription/Charge/Invoice/Entitlement/Finance). **Zero new
    regressions.**
  **Smoke matrix:** backend (session cookies) `/shop/plans` → 200, `/shop/subscriptions` →
  200, 0 error markers in either body. Frontend: logged in live via
  `/sign-in/login` with the dev shop-owner creds (`LoginForm[username]`/`LoginForm[password]`
  — note the login FIELD is `LoginForm`, not `SignInForm`, despite the controller being
  `SignInController`) → 302 + `_identity_frontend` cookie issued; `/navagoo-plans/index` →
  200, `/settings/index` → 200 (one grep hit was a false positive — "exceptions" inside a
  policy-text `<textarea>`, not a real error), `/` → 200. All 0 real error markers. Console:
  `php console/yii subscription-billing/run --dry=1` → clean 3-phase dry run (remind/charge/
  lapse), 0 due (both live `shop_subscription` rows are still free_period, next billing weeks
  out — matches the W3 verification note).
  **Bookkeeping:** this tracker + `ai_specs/05_PLANS/HANDOVER.md` (NVG-BEA-003 completion
  section) updated.
