# 06 — Admin panel, reports, marketing, notifications & scheduled jobs

> Written 2026-09-29 against branch `docs/codebase-map-2026-09-29` (= `origin/dev`), by reading the code only.
> Nothing was run: no app, no DB, no tests. Every `file:line` was read in the code.
> Claims that were inferred but not traced end to end are marked **UNVERIFIED**.
> Line numbers drift, so grep for the quoted symbol if a line no longer matches.

Abbreviations used in this file:

| Short | Full path |
|---|---|
| **RC** | `app/Http/Controllers/ReportController.php` (3,841 lines) |
| **RFS** | `app/Services/ReportFilterService.php` |
| **PMS** | `app/Services/PlatformMetricsService.php` |
| **WCU** | `app/Utility/WhatsAppCampaignUtility.php` |
| **FNT** | `app/Traits/FirebaseNotificationTrait.php` |
| **NU** | `app/Utility/NotificationUtility.php` |

---

## 1. Purpose & scope

This file covers the back office of kapitano.shop: the admin shell (`routes/admin.php`, the sidebar `resources/views/backend/inc/admin_sidenav.blade.php`, the `IsAdmin` middleware, Spatie permissions); dashboards, reports and CSV/XLSX exports; business settings; marketing (WhatsApp campaigns and templates, cart retargeting, loyalty, membership, referrals, customer segmentation); every notification channel (DB, FCM push, seller PWA push, WhatsApp, email, SMS, Facebook CAPI, GA4); and console commands and the scheduler.

Read these parts of `CLAUDE.md` first: the price contract (RAW / BASE / SYP, `order_rate_sql()`, `format_syp()`), "Admin reports share one filter layer", and "Marketing audiences share one segmentation layer".

### How admin access works

There are three layers:

1. **`auth`, then `admin`.** `admin` is `App\Http\Middleware\IsAdmin`. It lets through `user_type` `admin` or `staff`, returns 404 to everyone else, and forces staff password rotation (`StaffPasswordPolicy::isExpired()`).
2. **`permission:<name>`.** This is Spatie `PermissionMiddleware` (`app/Http/Kernel.php:92`). It is usually declared in the controller constructor with `->only(...)`, and sometimes on a route group.
3. **`Gate::before`.** In `app/Providers/AuthServiceProvider.php:29`, the `Super Admin` role passes every check.

Sidebar `@can` only hides links. A controller method with no `permission:` entry is open to **every staff account**.

The main admin group opens at `routes/admin.php:~163`:

```php
Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'admin', 'prevent-back-history']], ...)
```

Everything before it and after its closing `});` (`admin.php:1026`) is outside that protection (see §8).

---

## 2. Admin menu map

Permission = what the controller or route **enforces**. A sidebar-only `@can` is noted as such.

### Dashboards

| Menu item | Route name → URL | Controller@method | Permission |
|---|---|---|---|
| Dashboard | `admin.dashboard` → `/admin` | `AdminController@admin_dashboard` (:44) | **none on the route**. The view hides blocks with `@can('admin_dashboard')` (`backend/dashboard.blade.php:17`). |
| Dashboard AJAX blocks | `dashboard.*` POSTs (`admin.php:255-259`) | `AdminController@top_category_products_section` etc. (:271-419) | none, beyond admin/staff |
| Investor Report | `reports.investor` → `/admin/reports/investor` | `Admin\Report\InvestorReportController@index` | `investor_report` (ctor :24) |
| Kapitano Growth | `reports.kapitano_growth` → `/admin/reports/kapitano-growth` | `Admin\Report\GrowthReportController@index` | `kapitano_growth_report` (ctor :22) |
| (old bookmark) | `investor.dashboard` → `/admin/dashboard/inv` | redirects to kapitano-growth (`admin.php:672`) | — |
| Dashboard Reports | `admin.dashboard.report.view` → `/admin/report/view`; data from `admin.dashboard.report` | `AdminController@admin_dashboard_report(_view)` (:189, :196) | `dashboard_reports` (ctor :32) |
| Products Reports | `categories.products` → `/admin/categories/products/1` | `CategoryController@products` | `products_report` (`CategoryController.php:23`) |

### Sales and Customers → Loyalty Program

| Menu item | Route name | Controller@method | Permission |
|---|---|---|---|
| Sales → Cart Logs | `cart-logs.index` | `CartLogController@index` | `view_all_orders` (ctor :15) |
| Loyalty Points | `loyalty.index` | `LoyaltyController@index/edit/update` | `view_all_loyalty` / `edit_loyalty` |
| Membership | `membership.index` | `MembershipController@*` | `view_all_membership` / `edit_membership` |
| History | `loyalty_logs.index` | `LoyaltyLogController@index` | `view_loyalty_logs` |
| Order Setting Info (points per SYP) | `setting.edit` / `setting.update` | `SettingController` | `edit_setting` (whole controller) |
| Guidance Guide | `fixed_page.index` | `FixedPageController` | `view_all_fixed_pages` / `edit_fixed_page` |
| Register Invitations | `register-invitations.index` | `RegisterInvitationController@index` | `view_register_invitations` |

### Reports

The group is wrapped in `@canany([...])` at `admin_sidenav.blade.php:~1303`. URLs are under `/admin/`.

| Menu item | Route name → URL | Controller@method | Permission |
|---|---|---|---|
| Earning Report | `earning_payout_report.index` → `reports/earning-payout-report`, plus POST `net-sales`, `payouts`, `sale-analytic`, `payout-analytic` | `EarningReportController@index` (:48) | `earning_report` |
| Profit Report | `earning_payout_report.profit` | `EarningReportController@profitReport` (:31) | `earning_report` |
| In House Product Sale | `in_house_sale_report.index` | RC `in_house_sale_report` | `in_house_product_sale_report` |
| Seller Products Sale | `seller_sale_report.index` | RC `seller_sale_report` | `seller_products_sale_report` |
| Products Stock | `stock_report.index` | RC `stock_report` | `products_stock_report` |
| Stock zeroing | `stock_zeroing_report.index` | RC `stock_zeroing_report` | `stock_zeroing_report` |
| Products wishlist | `wish_report.index` | RC `wish_report` | `product_wishlist_report` |
| Cart Report | `cartreport.index` → `cart-report`; `cartreport.export` | `CartLogController@index2/exportData` | `cart_report` |
| Most Sale Items Report | `item.top` → `top_sale_item` | RC `item_top_sales` | `most_sale_items_report` |
| Sales & Profit Report | `reports.sales_profit_report` (+ `/export`) | RC `salesProfitReport` / `exportSalesProfitReport` | `sales_profit_report` |
| AOV | `reports.aov` | RC `aovReport` | `aov_report` |
| Cart Activity | `reports.cart_activity` | RC `cartActivity` | `cart_activity_report` |
| Repeat Purchase | `reports.repeat_purchase` | RC `repeatPurchaseRate` | `repeat_purchase_report` |
| Top Customer Report | `customer.top`, `customer.top.details`, `reports.customer.export` | RC `top_customers`, `top_customer_details`, `exportTopCustomer` | `top_customers_report` |
| Top Categories Report | `categories.top` | RC `top_categories` | `top_categories_report` |
| Sales Report By City | `city.top` | RC `sales_by_city` | `sales_by_city_report` |
| Coupon Usage Report | `coupon_usage_report.index` / `.export` / `.customer` / `.show` | `Admin\Report\CouponReportController` | `coupon_usage_report` (ctor :27) |
| Seller Response Time (+ the merged Seller Orders report) | `seller_response_report.index` (`?panel=<seller>` seller panel by AJAX, `&export=orders_csv`; `?open=<seller>` opens it) / `.export` | `SellerResponseReportController` + `App\Services\SellerOrdersReport` | `seller_response_report` **or** `seller_orders_report` (route group, `admin.php:788`) |
| ~~Seller Orders Report~~ (طلبات التجار) | `seller_orders_report.index` / `.show/{seller}`: **redirects** to Seller Response Time (`?open=<seller>`) since 2026-10-19; no menu entry | `SellerOrdersReportController` (redirects only) | either permission (`seller_orders_report` came with migration `2026_10_14_000001`, granted to every holder of `seller_response_report`) |
| Seller Inventory Updates (تحديث مخزون التجار) | `reports.seller_inventory` → `reports/seller-inventory` (`&export=csv`, `&fresh=1` recounts) / `reports.seller_inventory.show` → `reports/seller-inventory/{userId}` | `Admin\Report\SellerInventoryReportController` + `App\Services\SellerInventoryActivity` | `seller_inventory_report` (ctor; migration `2026_10_24_000001`, granted to every holder of `seller_response_report`) |
| Order Status Times (تقرير معدل تغيير حالة الطلبات) | `reports.order_status_times` → `reports/order-status-times` (`?range=` placement period, default last 30 days; `?employee=<user id>|none`; `&export=csv` one line per purchase) | `Admin\Report\OrderStatusTimeReportController` + `App\Services\OrderStatusTimes` (its docblock is the map): every status against every other incl. paid / cancelled / returned (`matrix()`, a pair going back in time left out) for the **purchases** placed in the period (combined order; a status is reached when its last live seller order reaches it), and the ones still open by the status they wait in (`stillOpen()`: since placed / since reaching it); all purchases or one follow-up employee's (`order_staff_details.follow_up_user_id`). Computed in PHP from the orders' status times (0.06 s for 1,600 purchases locally), no cache | `order_status_time_report` (ctor; migration `2026_10_28_000002`, granted to "full access") |
| Least / Most Sale Product | `reports.least_sold_products` / `reports.most_sold_products` | RC `leastSoldProducts` / `mostSoldProducts` | `least_sold_products_report` / `most_sold_products_report` |
| Product category distribution | `reports.product_category_distribution` | RC `productCategoryDistribution` | `product_category_distribution_report` |
| Order Time Report | `reports.order_time_report` | RC `orderTimeReport` | `order_time_report` |
| Customer Profiles (was Customer Demographics) | `reports.demographics` | RC `customer_demographics` | `customer_demographics_report` |
| Cart Retargeting | `cartretargeting.index` / `.update` / `.claim` / `.release` | `CartRetargetController` | `cart_retargeting` (ctor :29) |
| Order ratings → Follow-up (متابعة العملاء الذين لم يقيّموا) | `admin.order_ratings.follow_ups.index` / `.export` / `.take` / `.record`; `.assign` / `.unassign` / `.reopen` | `OrderRatingFollowUpController` + `App\Services\OrderRating\FollowUps` (§4.3a) | `follow_up_order_ratings`; the last three also `assign_order_rating_follow_ups` (migration `2026_10_27_000001`) |
| Order ratings → Follow-up after rating (المتابعة بعد التقييم) | `admin.order_ratings.after_rating.index` / `.export` / `.record`; `.reopen`; customer notes `admin.order_ratings.customer_notes.store` / `.update` / `.destroy` | `LowRatingFollowUpController`, `OrderRatingFollowUpController` + `LowRatingCases` / `LowRatingList` / `CustomerNotes` (§4.3a) | open: `view_order_ratings` or `follow_up_order_ratings`; acting `follow_up_order_ratings`; reopen also `assign_order_rating_follow_ups` |
| User Searches | `user_search_report.index` (`?platform=web|app|android|ios`, `&searcher=customer|guest`, `&min_count=`, `&export=csv`) | RC `user_search_report`: counts the searches made **in the period** from `search_events` (`search_submitted`: website `/search` from any page's box + the app's `v2/search/track`; dated since 2026-08-25), per term with website / app split and signed-in customers. Never read the old `searches` table for a period: it holds one all-time counter per term and only its last date (until 2026-11-10 "today" listed today's terms with their all-time totals). Clicks are not shown: most app clicks carry no term and the website results page records none. Pinned by `UserSearchReportTest` | `user_search_report` |
| Commission History | `commission-log.index` | RC `commission_history` | `commission_history_report` |
| Wallet Recharge History | `wallet-history.index` | RC `wallet_transaction_history` | `wallet_transaction_report` |
| Products added (Reports menu) | `admin.reports.products_added` → `reports/produtcs_added` (sic) | RC `productReportAdded` | `products_added_report` |
| Change Log | `audit_log.index` | `Admin\AuditLogController@index` | only `user_type == admin` (sidebar check and controller) |

### Marketing

| Menu item | Route name | Controller | Permission |
|---|---|---|---|
| Flash deals / Dynamic Pop-up / Custom Alert | `flash_deals.index`, `dynamic-popups.index`, `custom-alerts.index` | CMS controllers | `view_all_flash_deals`, `view_all_dynamic_popups`, `view_all_custom_alerts` |
| Email Templates (admin/seller/customer/all) | `email-templates.index` → `/admin/email-template/{type}` | `EmailTemplateController` | `manage_email_templates` |
| Newsletters | `newsletters.index`, `newsletters.send`, `test.smtp` | `NewsletterController` | `send_newsletter` |
| Notification → Settings | `notification.settings` | `NotificationController@notificationSettings` | `notification_settings` |
| Notification Types | `notification-type.*` | `NotificationTypeController` | `view_all_` / `add_` / `edit_` / `delete_notification_types` |
| Custom Notification + Scheduled | `custom_notification`, `custom_notification.scheduled*` (about 13 routes, `admin.php:981-1005`) | `NotificationController` | `send_custom_notification` |
| Custom Notification History | `custom_notification.history` | `NotificationController` | `view_custom_notification_history`; deletes need `delete_custom_notification_history` |
| WhatsApp Campaigns | `whatsapp_campaigns.*` (`admin.php:706-727`) | `Admin\WhatsAppCampaignController` | `manage_whatsapp_campaigns` (route group) |
| WhatsApp Templates | `whatsapp_templates.*` (`admin.php:732-743`) | `Admin\WhatsAppTemplateController` | `manage_whatsapp_campaigns` (route group) |
| Bulk SMS | `sms.index` (`routes/otp.php:47`) | OTP addon | `addon_is_activated('otp_system')` and `send_bulk_sms` (sidebar) |
| Subscribers / Coupon / App Popup | `subscribers.index`, `coupon.index`, `app-popups.index` | CMS / Kapitano | `view_all_subscribers`, `view_all_coupons`, `view_all_app_popups` |
| Splash screen (2026-11-11) | `splash-screens.*` | `Admin\SplashScreenController` (rules: `App\Services\SplashScreens`, app contract `docs/api/splash-screen.md`) | `view_splash_screens` looks, `manage_splash_screens` changes |

### Setup & Configurations

Controller: `BusinessSettingsController`, constructor at :23-64.

- Page permissions:
  - `features_activation`
  - `smtp_settings`
  - `otp_configurations` (OTP provider page and its test)
  - `payment_methods_configurations`
  - `order_configuration`
  - `file_system_&_cache_configuration`
  - `social_media_logins`
  - `whatsapp_chat`
  - `facebook_comment`
  - `analytics_tools_configuration` (also the pixel writer)
  - `google_recaptcha_configuration`
  - `google_map_setting`
  - `google_firebase_setting`
  - `shipping_configuration`
  - `general_settings`
  - `seller_commission_configuration`
  - `seller_verification_form_configuration`
- The shared writers need **`update_settings`** (:49): `update`, `updateActivationSettings`, `updatePaymentActivationSettings`, `env_key_update`, `import_data`.
- The sidebar shows "Staff Password Policy" (`staff_password_policy.index`) under `general_settings`. **UNVERIFIED:** which permission the method itself enforces. It is not in the constructor list.
- "Seller WhatsApp Order Alerts" is `SellerWhatsAppSettingsController` with `permission:seller_whatsapp_settings` (route group, `admin.php:676`).
- "App Update Settings" is `app.settings` → `PageController@indexApplicaion`. It is registered **outside** the auth/admin group (`admin.php:~128`). It is protected only by `permission:edit_website_page` in `PageController.php:23`: guests are refused, but `IsAdmin` and password rotation are skipped.

### Permission-reference drift (`docs/permissions-reference.md`)

- `stock_zeroing_report` and `manage_whatsapp_campaigns` are used in code and seeded by migrations `2026_09_20_000002` and `2026_09_24_000002`, but are **missing** from the doc.
- `view_kapitano_growth` is listed in the doc but used nowhere. The code uses `kapitano_growth_report`.

---

## 3. Reports reference

### 3.1 Shared layer: `ReportFilterService` (RFS)

`filters(Request, $extra)` (:39-93) returns: `range` (20 presets, `dateBounds` :98), `from`, `to`, `delivery_status[]` (whitelisted), `payment_status`, `payment_type`, `seller_id`, `entry_type` (seller entry type: `self` / `managed` / `unclassified`, '' = all — `App\Services\SellerEntryType`), `category_id`, `brand_id`, `city`, `search`, `exclude_cancelled`, `per_page` (default 25) and `sort`.

`entry_type` is applied by `applyOrderFilters()` on `$table.seller_id`; a report that filters sellers on another column (`products.user_id` in the stock reports and the growth report's products, `shops.user_id` in the seller sale report, `n.seller_id` in the seller response report) calls `applyEntryType($q, $f, $column)` next to its `seller_id`. `_filters` shows the select whenever `seller` (or `entry_type`) is in `show`.

`exclude_cancelled` **defaults to true**. It is read from the checkbox only when `_submitted` is present (:87). It drops both `cancelled` and `returned` orders (label "Exclude cancelled and returned orders").

**`count_by` ("Count Orders By", `RFS::COUNT_BY`, since 2026-10-14)** — `checkout` (**default**: a purchase split between sellers counts once, `combined_order_id`, else the order itself; `checkoutKeySql()`) or `seller` (one per `orders` row). Every order count, average per order, "repeat" and rate of an order count goes through `orderCountSql($f)` (SQL, `COUNT(DISTINCT …)`, safe with lines joined) or `orderCount($rows, $f)` (loaded rows); quantities, lines, customers, visits and carts never do. `queryString()` carries it; `_filters` shows it with `'count_by'` in `show`. Wired in: top customers (+ details "in the filters" numbers), sales by city, most sold, top categories, in-house sales, seller sales (in basket mode the totals count distinct purchases, not the sum of sellers), investor (`PMS::orderSummary()`: `orders`/`aov`/`repeat`/`frequency`/rates follow it, `checkouts` is always per purchase, `seller_orders` always per row), growth, dashboard analytics, traffic sources, seller profitability (`SellerPricingReport::orderTotal()` for the total), and the pre-RFS pages through `shared/_count_by_form` or their own form: AOV (highest/lowest = per purchase total; the monthly chart now sums SYP), repeat purchase, customer demographics, order time, sales profit, coupon usage (`finishStats($row, $f)`: averages per use or per seller order). Pinned by `ReportCountByTest` + both modes in `DashboardAnalyticsTest`, `PlatformMetricsServiceTest`, `CouponUsageReportTest`, `TopCustomersLifetimeValueTest`.

`applyOrderFilters($q, $f, $table='orders', $epochDate=false)` (:194-222):

- The date column is `orders.created_at` **unless `$epochDate=true`**. Only PMS passes `true` (`PMS:370`).
- `created_at` is stored 8–9 h off the app timezone (comment at :190), so every RC report built on RFS ranges on the skewed column.
- A `delivery_status` filter overrides `exclude_cancelled`.

Money and output helpers:

| Helper | Line | What it returns |
|---|---|---|
| `lineRevenueSql()` | :234 | `price + tax`, BASE |
| `lineRevenueSypSql()` | :244 | `lineRevenueSql() × order_rate_sql` |
| `orderAmountSypSql($col)` | :250 | `$col × order_rate_sql` |
| `streamCsv()` | :305 | UTF-8 BOM, streamed when given a generator. RC `rowsForExport()` (:3772) uses `cursor()`. |

Views are in `resources/views/backend/reports/shared/`: `_filters`, `_header`, `_kpis`, `_growth`, `_datatables`, `_scripts`, `_styles`, `_customer_styles`, `_customer_details_modal`, `_select_field`, `_number_field`, `_checkbox_field`, `_count_by_form`.

Pages on the shared layer: cart_retargeting, seller_inventory, investor, kapitano_growth, in_house_sale, seller_sale, most_sale_item, sales_by_city, top_categories, top_customers, seller_response, stock_report, stock_zeroing, user_search, wish_report.

Everything else uses its own hand-rolled old UI and filters.

A fact several findings below depend on: **`order_details.price` is the line total (unit × qty).** Evidence: `OrderController.php:648` sets `cart_product_price(...) * quantity`, and `OrderController.php:386` divides by quantity to get the unit price.

### 3.2 Report table

Legend: "order rate" = `order_rate_sql` (rate on the order date) = correct. "today" = `single_price` / `convert_price` = wrong for past orders.

| Report | Location | Computes | Currency / rate | Cancelled |
|---|---|---|---|---|
| Customer demographics | RC `customer_demographics` -> `App\Services\Customers\CustomerDemographics` (rebuilt 2026-11-09, see 4.2a) | Account only: active customers by registration or profile-completion date; profile completeness, gender, age (under 18 to 55+, not recorded), governorate, what is missing, the customers list + CSV | none (no money) | n/a (no orders) |
| Products added | RC `productReportAdded` | Products created in range, list-price and cost totals; filter "added by" (merchants / employees, `ProductCreator::constrain()`), column + counts of who added each product | rows `product_price_to_syp()` (RAW at the product's rate → SYP), totals `raw_to_base_sql` → BASE → `convert_price()`; min/max price typed in SYP (`syp_price_bounds_to_base`) | n/a |
| Order time | RC:638-855 | Counts by hour, weekday and month (`created_at`) | no money | **Included** |
| Most sold products | RC:859-1048 | Units and revenue per product | order rate, but see bugs | **Included** |
| Least sold products | RC:1054-1261 | Same, plus never sold | order rate, same bugs | **Included** |
| Category distribution | RC:1272-1421 | Product count per direct `category_id` (no subtree, unpublished included) | none | n/a |
| Sales & Profit | RC:1440-2201; export RC:2203 → `App\Exports\SalesProfitReportExport` | Sales, "cost", profit, per-day/vendor charts, cancel rate and reasons | **BASE, never converted**. `SUM(od.price)` (:1640-1647); view prints raw `{{ $totalSales }}` (`sales_profit_report.blade.php:459-493`) | **Included** unless a status is picked; cancellations reported separately (:1593, :2139) |
| AOV | RC:2211-2305 | Month/year AOV, highest order, monthly chart | KPIs SYP at order rate (:2229); **chart is BASE `SUM(grand_total)`** (:2262) | Excluded (:2220, :2242, :2250, :2266); paid only |
| Repeat purchase | RC:2306-2407 | Customers with more than one paid order **in the same calendar month** | none | **Included** (paid only) |
| Cart activity | RC:2411-2513 | `carts` rows per month; list and top customers | `single_price(carts.price)` (today; fine for live carts) | n/a |
| Stock zeroing | RC:2546 (query :2591) | Stock zeroed by "unavailable" cancellations, item deletes and seller reports (`stock_zeroings.created_at`; "Zeroed by" tags seller reports) | none | n/a |
| Products stock | RC:2661 (query :2713) | Qty and value per product | `raw_to_base_sql(ps.price) * qty` (:2718); view `single_price`, CSV `convert_price` (:2701), both today's rate, which is right for stock | n/a |
| Top categories | RC:2792 (query :2838) | Revenue per category; `level=root` rolls up | `lineRevenueSypSql` ✔ | RFS default (excluded) |
| Most sale items | RC:2933-3047 | Order KPIs, status donut, top 15 lines, trend | `orderAmountSypSql` / `lineRevenueSypSql` ✔ | RFS; the donut forces `exclude_cancelled=false` (:2953) |
| Top customers | RC:3053 (query :3096) | Spend per customer (`user_type=customer`); column + sort `ltv` (`customerLifetimeValueQuery()`) = **delivered** orders only, minus their cancelled/returned lines (price + tax — `grand_total` never shrinks for them; `refund_requests` is not installed), floored at 0 per order, **over the report's filters** (the details page shows the whole lifetime, same query without filters); under it "N× the average". KPIs "Average LTV" (Σ LTV / listed customers with a delivered order, with the store-wide `storeLifetimeValue()` and the % gap under it) and "Median LTV" (`medianLifetimeValue()`). Filter `count_by` = `checkout` (default: a purchase split between sellers counts once, `combined_order_id`) or `seller` (every `orders` row) drives orders, average order, "Repeat" and min orders. Pinned by `TopCustomersLifetimeValueTest` | `orderAmountSypSql` ✔ | RFS default |
| Top customer details | RC:3153-3315 | One customer: orders, AOV per checkout, products | SYP ✔ (:3180-3181) | Lifetime stats exclude cancelled (:3193, :3261) |
| Sales by city | RC:3321 (query :3360) | Revenue per JSON `shipping_address.city` | ✔ | RFS default |
| In-house product sale | RC:3406 (query :3446) | Qty and revenue per admin product, from order lines | ✔ `lineRevenueSypSql` | RFS default |
| Seller products sale | RC:3504-3599 | Revenue per shop | ✔ `orderAmountSypSql` | RFS default |
| Wishlist | RC:3605-3693 | Wishes per product | none; the CSV "Total Sales" is the lifetime `num_of_sale` (:3683) | n/a |
| User searches | RC:3699-3767 | `searches` by `updated_at` | none | n/a |
| Commission history | RC:3779-3809 | `commission_histories`, paginated 10 | **raw BASE numbers, no currency** (`partials/commission_history_section.blade.php:55-56`) | n/a |
| Wallet history | RC:3811-3839 | `wallets` rows | `single_price` (today) (`wallet_history_report.blade.php:67`) | n/a |
| Top customer XLSX | RC:56 → `TopCustomersExport` | Its own query: all user types, BASE | BASE unconverted | Excluded |
| Dashboard | `AdminController.php:44-185` | Totals, charts, top lists | Mixed; see below | Mixed: `total_sale` is delivered only (:112); others exclude only cancelled |
| Dashboard Reports page | `AdminController.php:196-261` | Monthly **counts** (`total_sale` is a count of delivered orders) | none | — |
| Earning report | `EarningReportController.php:48-215` | Monthly sales chart, payouts, top categories/brands | `total_syp` order rate (:52, :57); subscriptions at today's `currency_syp_factor` (:126); payouts `single_price` | Delivered only |
| Profit report | `EarningReportController.php:31` + `profit_report.blade.php:47` | `num_of_sale × (unit_price − purchase_price)` | **RAW prices, lifetime counter** | n/a |
| Coupon usage | `CouponReportController` + `app/Services/CouponUsageReportService.php` (694 lines) | Per coupon / customer / order; legacy orders show as "Unknown" | SYP order rate (service :315, :364); Orders sheet uses `convert_order_price` | **Included**: its own `filters()` near-copy of RFS without `exclude_cancelled` (service :64, :143, :191; `baseQuery` :247-306) |
| Kapitano Growth | `GrowthReportController.php` + PMS | New customers/sellers/products (`created_at`), orders/GMV/buyers (`orders.date`), health, seller and category growth | SYP per order via `order_exchange_rate` (`PMS:352-364`) | RFS default via `PMS::counts` (:400) |
| Investor | `InvestorReportController.php` + PMS | Period summary, sellers, categories, cities, cohorts | SYP | RFS default; `cohorts()` loads **every** non-cancelled order on each view (`PMS:565-570`) |
| Seller response | `SellerResponseReportController.php` | Alert → answer medians and P90, grades A–D/X (min 10 alerts, :284-312) | none | Ignores status filters; excludes `closed_reason=cancelled` (:194-200) |
| Seller orders (columns + seller panel of Seller Response Time, merged 2026-10-19) | `SellerOrdersReport` (service, map in its docblock) | **Only the seller own answer counts, whatever staff did** (decided 2026-10-14). Per seller order `state` = unavailable (`orders.seller_unavailable_at` / alert "out of stock") > seller_confirmed (`seller_confirmed_at` / "accepted", not when staff confirmed on their behalf: alert `staff_on_behalf` or follow-up log `seller_confirmed_by_staff`) > cancelled (for a seller reason: `OrderCancelReasons::SELLER_UNAVAILABLE` / `SELLER_FAULT`) > cancelled_other (any other reason, none, or returned - **not held against the seller**, decided 2026-10-19) > awaiting (also when staff confirmed / delivered it; `staff_handled` flags those). The table shows: received = **confirmed** (in full + partly: some, not all, products flagged) + **not confirmed** (the seller flagged every product or named none - only what the seller answered) + **cancelled for another reason** + **awaiting** (shown as "Seller did not answer" / لم يردّ التاجر: the silent orders **plus `cancelled`**, the ones our staff cancelled for a seller reason - decided 2026-10-19; `awaiting_staff` = how many of them our staff handled), and **unavailable products** (flagged products of partial orders + every product of the orders reported wholly unavailable or cancelled as `out_of_stock`, **+ the products our staff deleted from an order with "delete and set its stock to zero"** - decided 2026-10-20, read from `stock_zeroings` source `order_item_delete` as `staff_removed_lines`; they count in the order products (`totalLinesOf()`, `judged_lines`), are listed with the order marked "deleted by our staff as not available" with who did it, show apart as `staff_removed` / `staff_times`, never change the seller answer, and an order cancelled for another reason holds none; the panel shows the reported variant's stock now, from `product_stocks`, never `products.current_stock`); every rate out of the orders judged (`judged` = received - cancelled other; products out of `judged_lines`). The seller panel adds the score card (same numbers as the row), `cancelBreakdown()` (reasons held against the seller, then the others by `OrderCancelReasons` group, returned, none; each a `cancel_reason` filter) and the orders with their products, sorts by each rate both ways (`RATE_SORTS`), a column heading sorts by its rate too | `orderAmountSypSql` ✔ | RFS on `orders.date` (epoch), `exclude_cancelled` forced off, default last 30 days; only orders since `Order::SELLER_CONFIRMATION_SINCE`, in-house left out. Alert columns filter on `seller_order_notifications.created_at`, order columns on `orders.date`; an alert counts once it went out (`send_status = accepted`), **also when the order was cancelled before the seller answered** (an unanswered alert, decided 2026-11-08, so a seller's alerts match their orders received; failed sends, no number and orders with no alert stay out); sellers with orders but no alert are listed under the graded ones; the panel is paginated |
| Seller answer on Admin → Orders (2026-10-26) | `SellerOrdersReport::answersFor()` / `listBadge()` / `waitingForSeller()` / `lineAnswer()` / `orderIdsWithAnswer()`, read by `OrderController::all_orders` | The orders list uses the report's own rules, so its labels match the seller dashboard and Seller Response Time. **Checkout row**: no status of its own (decided 2026-10-26); "بانتظار تأكيد البائع x/n" only while a seller is still awaited (`waitingForSeller()`: no answer, the order neither cancelled / returned nor moved on by our staff - confirmed for them, shipped, delivered; older orders: still pending, nothing answered), x = the open seller orders not awaited, n = the open seller orders (not in-house, not cancelled / returned); otherwise nothing. **Each seller-order row** (and its "+" panel header) shows `listBadge()`: مؤكد كلي / مؤكد جزئي / غير مؤكد; بانتظار تأكيد البائع while still awaited; لم يردّ التاجر once the order moved on without an answer - our staff confirmed / shipped it, or cancelled it for a reason held against the seller - the report's own (`OrderCancelReasons::SELLER_UNAVAILABLE` + `SELLER_FAULT`: product not available, seller late, wrong price or description; not "rest of the order not available", usually another seller's products, confirmed 2026-10-26) - then "ملغى من قبل الإدارة" under it. A seller who answered keeps their answer whatever happened next, with who closed the order under it - exactly the reports' state, so the list, Seller Response Time, Seller Inventory Updates and the seller dashboard count every order alike (decided 2026-10-26). An order the seller never answered and that was cancelled for another reason or returned (the reports' cancelled_other) shows only who closed it: ملغى من قبل العميل (the customer cancelled it - `orders.cancelled_by` = the order's customer, as the order timeline reads it - or our staff did for a reason of the cancel list's "Requested by the customer" group) or ملغى من قبل الإدارة (anyone else, or nobody recorded), مرتجع for a return; the reason is the tooltip. The "Seller Answer" filter keeps the dashboard's five answers (its لم يردّ التاجر holds both the awaited and the moved-on ones). **Each product** in the "+" panel: متوفر / غير متوفر (flagged, or the whole order reported missing) / بانتظار التاجر, and the products our staff deleted as not available are listed struck through (`staffRemovedLinesFor()`). The "Seller Answer" filter (`seller_confirmation` = `OrderController::SELLER_ANSWER_FILTERS`; the old `confirmed` reads as `confirmed_full`) is `whereIn(orders.id, orderIdsWithAnswer())`. Orders the report does not judge (in-house, before `Order::SELLER_CONFIRMATION_SINCE`) keep `Order::sellerConfirmationBadge()`; the order page, combined order page and follow-up screens still use that badge | none | one query per page (`order_ids` narrows every sub-query of `ordersQuery()`) |
| Seller inventory updates (2026-10-24) | `SellerInventoryActivity` (report) + `SellerStockUpdates` (the record; maps in their docblocks) | Does each seller update their own stock, and how often, and how many orders they refused as out of stock. **Source: its own table `seller_stock_updates`, never the audit log** (it can be pruned): written by the `ProductStock` / `Product` hooks when a seller who owns the product changes a quantity (`kind = qty`, before / after) or edits the product (`kind = save`, `stock_rows`). The product form (web and app) recreates every variant even for a quantity-only change, so a save is judged at the end of the request (`SellerStockUpdates::flush()`): the variants kept at `Product::saving` against the new ones - a quantity that differs is a `qty` row, and a `save` row only for a variant added / removed, a variant price / SKU / photo changed, a `CONTENT_FIELDS` change, a new product or a save that changed nothing; `actor = staff` + `staff_id` under "Log in as seller"; order stock moves (`OrderStockUtility` inside `quietly()`, `/order` and `/so/` paths), admin screens, partner sync and the console are never recorded. History before the table copied from the audit log by migration `2026_10_24_000002` and again by `2026_10_25_000001` with the same comparison, rebuilt from the audit trail (`source = backfill`; prices are not compared there, the audit holds them before the price rules); `business_settings.seller_stock_updates_since` = start of the record (2026-09-20), every period is cut to it. Status: partner store > no products on sale > regular (an update in >= 50% of the weeks, Saturday weeks) > occasional > staff only > idle > never. Per seller: weeks with an update + a week strip, update days, quantity edits, product saves, products changed, days between updates, last update (seller / staff), variants out of stock now, products not updated in N days, live orders received and **orders refused as out of stock** - every order figure is Seller Response Time's own, read from `SellerOrdersReport` (`sellersQuery()` / `ordersOf()` / `rates()`, unified 2026-10-26, never counted again here): orders received (cancelled included), refused (= its Partly + Not confirmed), refused whole (= Not confirmed), refused products (= Unavailable products), the refusal rate (= its `unavailable_rate`, out of the orders it judges) and the refused orders list, over the date range the user picked (`orderRange()`), not the period cut to the start of the stock record; the order cards are its totals (every seller, partner stores included even when hidden), the stock cards leave partner stores out. Written summary (`findings()`) and "contact first" (`contacts()`, refusals first). The sellers table is paged (`per_page(25)`); cards, bar, summary and CSV cover every seller; cached 10 minutes per period and stale window | none (counts) | Orders received: `orders.date` in the period, `void_order_statuses()` left out; refusals count every status |
| Cart report | `CartLogController@index2` (:43) / `exportData` (:79, `CartReportExport`) | Raw listing of `carts` rows (user/guest phone, product, date) | none | n/a |

### 3.3 Verified report bugs

**Most / least sold products double-count**

- `price * quantity` at RC:913, :978 and :1109. `order_details.price` is already the line total, so revenue is inflated by the quantity factor, and tax is omitted.
- The `leftJoin product_stocks` on `product_id` (RC:~891, :~1087) multiplies both `sold_qty` and revenue by the number of variants.
- For least sold, the date filter sits in WHERE on a left-joined table (:1119-1138), so never-sold products disappear once dates are set.
- The same `price * quantity` bug is in the dashboard seller-products block (`AdminController.php:380`).

**Sales & Profit "cost" is not a cost**

- Cost = `product_stocks.price`, i.e. the current **selling** price (:1655-1700; the in-code comment admits it). So "profit" is roughly today's price minus the historic price.
- The vendor chart uses `COUNT` instead of `SUM` (:2101, :2104).
- All amounts are BASE printed raw.

**Earning report missing braces**

At `EarningReportController.php:118-132`:

```php
if ($product_sale->time == $month)
    $sale_stat_data['total'] += ...;
    $sale_stat_data['total_syp'] += ...;   // runs for every row
```

Only the first statement is conditional. `total_syp` therefore adds every month's sales into every month, so the SYP chart and `sales_this_month` are wrong. The same pattern repeats for seller and customer subscriptions.

**Earning index writes to the DB on GET:** `packageAmountStoreIntoPackagePaymentTable()` runs on page load (:210-212, :574-589).

**Dashboard (`AdminController@admin_dashboard`):**

- `sale_this_month`, `admin_sale_this_month`, `seller_sale_this_month` and `total_inhouse_sale` are BASE shown with `single_price`, i.e. today's rate (`dashboard.blade.php:237, 255, 266, 588`).
- `total_sale` is BASE shown with `number_format_short` and no currency (:226).
- `cached_graph_data` (`Cache::remember` 86,400 s, :49-72) sums the lifetime `num_of_sale` across every product and is never forgotten. **UNVERIFIED** whether the view still uses it.
- `$admin_id` is the first admin only.

**SQL injection (staff-only):** `$request->interval_type` is concatenated into raw SQL in the dashboard AJAX blocks (`AdminController.php:282, 316, 337, 373, 387, 429`), e.g. `'... INTERVAL 1 ' . $request->interval_type . ')'` at :282. These routes are inside the admin group, so any staff account can inject.

**Currency:** the AOV chart shows BASE while its KPIs show SYP (RC:2262). Wallet and commission history use today's rate or no currency.

**Offset `created_at`:** used by nearly every RC report. Only PMS, `OrdersExport` and parts of `top_customer_details` use the `orders.date` epoch.

### 3.4 Exports (`app/Exports`)

| Class | Called from | Currency | Loading |
|---|---|---|---|
| `TopCustomersExport` | RC:56 | BASE raw; includes sellers and admins | `FromCollection` (all rows) |
| `SalesProfitReportExport` | RC:2206 | BASE; filters are a copy of the page's | all rows |
| `CartReportExport` | `CartLogController:82` | — | all rows |
| `CompletedOrdersExport` | `OrderController:230` | `single_order_price` ✔ | all rows |
| `CouponUsageReportExport` + `CouponUsageReport/*Sheet` | `CouponReportController:180` | SYP ✔ | `FromQuery` (chunked) |
| `OrdersExport` | `OrderController@orderBulkExport` (Admin → Orders → Export, for the accountant) | **US dollars** (BASE, no rate) | `FromQuery` over the list's own query (`OrderController::filteredOrdersQuery()`, scope from the form's hidden `list` field), so it follows every filter of the page. **One row per order**; per product line, joined with ` | ` in the Products order: quantity and per piece the seller price and the price before discount (`OrderLinePricesUtility`: kept on `order_details.seller_price` / `price_before_discount` since 2026-10-10, else the product's current prices), the purchase price (frozen, else current) and the price after discount (`price / quantity`); `-` = none. Then total pieces, total purchase (live lines), products total, coupon, coupon discount, shipping, order total (products total − coupon + shipping). Pinned by `OrdersAccountantExportTest` |
| `CustomersExport` | `CustomerController:142` | `balance` raw | `FromQuery` |
| `ProductExport` | `ProductController:96` | UNVERIFIED | `get()` |

`Seller/OrderController@orderBulkExport` uses a different class, `App\Models\OrdersExport` (ticked ids only).

### 3.4a Seller price currency and the reports (2026-10-25)
Order money is unchanged: `order_details.price/tax/purchase_price`, `orders.*`, `commission_histories.seller_earning/admin_commission` stay BASE (USD) for every seller, so every report on them is unchanged. RAW columns (`products.*` prices, `product_stocks.*`) are in `products.price_currency`: sum or compare them only through `raw_to_base_sql()` (its `CASE` follows the currency) or `product_price_to_syp()`. Fixed in that change: the earning "profit report" (bare RAW numbers, now labelled per product currency, dollars as before), the Sales & Profit per-order rows and detail modal (they took the stock price as the cost; now the frozen purchase price, else the stock price at the product's rate, like the totals), Seller Profitability "due" (the balance is in the seller's currency). The admin product export gained a `Price Currency` column. Reporting views: section F of the SQL file.

### 3.5 Reporting database user (`kapitano_reports`)

**What it is.** Management's own assistant builds its daily, weekly and monthly reports straight from the database. It connects as a read-only MySQL login, `kapitano_reports@127.0.0.1`, reached through an SSH tunnel.
- That login sees **only** the schema `kapitano_reports`. The schema holds views over the live tables, and each view keeps the real table's name: `kapitano_reports.orders`, `.order_details`, `.combined_orders`, `.products`, `.shops`, `.users`, and so on.
- The views are owned by a locked account, `kapitano_reports_definer@localhost`, which has SELECT on the listed tables and nothing else.
- Everything is created by `sqlupdates/kapitano_reports_readonly_access_2026-10-04.sql`. Its header is the runbook: preconditions, SSH key restrictions, creating the login by hand, and rollback. It is not a migration: the app's DB user must not hold CREATE USER.

**Why views and not column grants.** No personal data may be exposed.
- An order's governorate and city live only inside `shipping_address` JSON, next to the customer's name and phone. The views extract `ship_state` / `ship_city`.
- `payment_details`, `additional_info`, `cancel_note`, ticket text, cart snapshots, audiences, bank columns, tokens and IPs are never selected.
- A raw guest `temp_user_id` is also never selected, because it works as the guest's credential in the app. Carts get a hashed `guest_key` instead.

**What the views compute for the reader:**

| Column | Meaning | PHP original |
|---|---|---|
| `rates.syp_rate_now` / `market_multiplier` | Today's BASE → SYP factor, and the RAW → BASE factor for market products | `currency_syp_factor()`, `raw_to_base_multiplier()` |
| `orders.syp_rate`, `order_details.syp_rate`, `combined_orders.syp_rate`, `*_syp` | Rate on the order's own date (a checkout uses its oldest order) | `exchange_rate_at(order_timestamp())` |
| `order_day`, `order_at_syria` | Syria date and time from the epoch `orders.date` (UTC+3), whatever the client's session timezone | `PlatformMetricsService` buckets |
| `combined_orders.shipping_fee` / `payable_total` | Delivery fee of the checkout / what the customer owes | `combined_order_payable_total()` |
| `products.base_unit_price` / `base_purchase_price` | RAW prices converted to BASE | `raw_to_base_sql()` |

**These are SQL copies of PHP rules.** Changing the exchange-rate rule, the payable-total rule, or renaming or dropping any column a view reads breaks management's reports. When that happens:
1. update the SQL file and bump the `about` view's version;
2. re-run the file on the server;
3. tell management **before** deploying.

`tests/Feature/ReportingViewsRuntimeTest.php` is opt-in and live. It is skipped while the schema is absent. It compares every order's and checkout's rate and payable total with the PHP functions, checks a day's sales against `PlatformMetricsService`, and fails if a personal column appears.

**Notes for whoever writes the reports:**
- **Void orders** are `delivery_status IN ('cancelled','returned')`, available as `is_void`.
  - A return is `returned`, plus a reason key in `cancel_reason`.
  - A partial return shows only on `order_details.delivery_status`.
  - There is no refunds table.
- **Delivery fee** is per checkout: `combined_orders.shipping_fee`. `orders.grand_total` does not include it. Count it once per `combined_order_id`, never per seller order. Most areas charge 0 (`cities.cost`).
- **Discounts** are coupons only:
  - `orders.coupon_discount` is each seller order's share; `combined_orders.coupon_discount` is the checkout total.
  - Product (fake) discounts are already inside `order_details.price`.
- **Cost price** is `products.purchase_price`, RAW, and is **today's** value: orders do not keep a copy. For partner products it is derived from `partner_sources.cost_discount_percent`.
- **Commission:**
  - The rule is in the `settings` view: `seller_commission_type`, then `vendor_commission`, or `shops.commission_percentage`, or `categories.commision_rate`.
  - `commission_histories` holds the amounts actually booked, and misses most cash-on-delivery orders. `PlatformMetricsService::commissionFor()` estimates commission from the rule instead.
  - Since seller pricing (2026-10-06) an order line keeps its cost: `order_details.purchase_price` (BASE, whole line), and its commission is `price − purchase_price`. The reporting views do not expose that column yet: adding it means updating `sqlupdates/kapitano_reports_readonly_access_2026-10-04.sql` and telling management.
- **Dates.** Use `order_day`. Laravel `created_at`-style columns are another matter:
  - This covers `users.created_at`, `delivered_date`, `payment_settled_at`, `cancelled_at` and the follow-up times.
  - They are Syria time from 2026-09-02 on. Before that they are 8–9 h ahead: 9 h until March 2026, then 8 h.
- **Not stored at all:**
  - result counts of a search: "searched and found nothing" can only be approximated as a `search_submitted` event with no click;
  - the cash amount collected per driver: only who delivered (`order_follow_ups.delivery_employee_id` → `delivery_agents`) and when the order was marked paid (`payment_settled_at`);
  - web Paymera attempts: only the last `combined_orders.payment_id` survives, while the app logs every attempt in `paymera_app_checkouts`.
- **Section C of the file** covers `order_ratings`, `traffic_*` and `users.deactivated_at`. It only works once those features are deployed. Until then the run stops there and harms nothing.

---

## 4. Marketing features

### 4.1 WhatsApp campaigns (BuzzBip)

**Tables**

Created by `database/migrations/2026_09_24_000001_create_whatsapp_marketing_tables.php`. Templates come from `2026_09_24_000005`.

| Table | Key columns and model notes |
|---|---|
| `marketing_contacts` | phone (unique, E.164), name, tag, notes, `opted_out`, created_by. Model `MarketingContact`. |
| `whatsapp_campaigns` | name, template_id, coupon_id / coupon_code, discount_text, name_fallback; `audience` JSON (resolved only when sending starts); `status` `scheduled \| preparing \| sending \| paused \| completed \| cancelled`; scheduled / started / finished / polled timestamps; denormalised recipients / accepted / failed / delivered / read / clicked counters; later `template_ref_id`, `template_values`, `header_media_id`, `header_image`. Model `WhatsappCampaign`: `isEditable()` = never started and scheduled or paused (:75); `isDeletable()` = not preparing or sending (:82). |
| `whatsapp_campaign_recipients` | campaign_id, user_id, contact_id, name, phone; `send_status` pending/accepted/failed, attempts, `bulk_group_id`, `wa_state` accepted/sent/delivered/read/failed, delivered_at, read_at; click fields **`token`** (16 chars, unique), clicked_at, clicks, clicked_store. Unique `(campaign_id, phone)`. |
| `whatsapp_templates` | buzzbip_id, name, category, language, header_type none/text/image, body; `variables` JSON (first_name, coupon_code, coupon_discount, custom); `buttons` JSON (at most 10 buttons, 2 URL, 1 phone); `state` DRAFT/PENDING/APPROVED/REJECTED or local ARCHIVED. `CLICK_BASE` is hard-coded to `https://kapitano.shop/wc/` (`WhatsappTemplate.php:54`), so tracking links always point at production. |

**Create flow**

`WhatsAppCampaignController@store` (:212) → `campaignInput` (:359-424):

- validates the input;
- resolves the audience once, just to count it, and refuses 0 recipients;
- optionally uploads a header image via `BuzzBipService::uploadMedia`;
- creates the row with status `scheduled`.

Other controller actions:

| Action | Lines | Notes |
|---|---|---|
| Edit | :239-293 | Locks the row and rebuilds recipients |
| Delete | :314-348 | Also archives an unused template |
| Test send | :458-501 | One real message, token `test` |
| Export | :580-614 | Streamed CSV, chunks of 1000. Names are not escaped for leading `=+-@` (CSV formula injection) |

**Engine: `WCU::process($batch)` (:1151-1199)**

Run every minute by `whatsapp-campaigns:process`. Constants (:35-52): `BATCH_SIZE` 100, `MAX_ATTEMPTS` 3, `POLL_DAYS` 3, `POLL_EVERY_MINUTES` 5, `ATTRIBUTION_DAYS` 7, dial code 963.

1. **Due campaigns** go to `prepare()` (:922-971).
   - A guarded UPDATE moves `scheduled → preparing` (atomic claim).
   - `resolveAudience()` (:440-605) runs, and recipients are `insertOrIgnore`d in chunks of 500 with token `Str::lower(Str::random(12))`.
   - A guarded UPDATE then moves `preparing → sending`.
   - On an exception the campaign goes to `paused` with a note.
   - A process killed mid-prepare leaves the campaign in `preparing` forever. It can only be cancelled.
2. **Sending campaigns** go to `sendBatch()` (:978-1069).
   - Takes the next 100 pending rows and sends each with `BuzzBipService::sendTemplate`.
   - Re-reads the campaign status every 20 messages, so pause and cancel take effect quickly.
   - HTTP 403 pauses the whole campaign ("balance probably empty").
   - 429, 5xx or a connection error: `attempts++`, the row stays pending, and the batch stops.
   - Any other error marks the row failed.
   - When nothing is pending, the campaign becomes `completed`.
3. **Poll** (:1077-1123).
   - Covers campaigns started within the last 3 days, polled more than 5 minutes ago.
   - `BuzzBipService::messageStates()` (:359) sets `wa_state`, `delivered_at` and `read_at`.
   - BuzzBip has no webhook, so polling is the only source of delivery state.

**Audience: `resolveAudience()`**

Sources, deduplicated by E.164 phone:

- `CustomerSegmentUtility` customers;
- picked users;
- contacts that are not opted out (optionally by tag);
- carts not ordered (`CustomerSegmentUtility::cartQuery`);
- pasted numbers.

Removed afterwards:

- opted-out numbers;
- numbers messaged within `skip_recent_days`;
- the **Exclude** block (`excludedPhones` :612): phones, users, contact tags, recipients of earlier campaigns by scope, cities;
- customers who ordered within `skip_ordered_days`;
- customers who already used the coupon (:577-603).

Opt-out is a manual admin toggle only (controller :832). There is no inbound STOP. Opt-out is **not re-checked at send time**, so a number opted out after prepare still gets the message.

**Per-message values: `sendParams()` (:177-226)**

- `first_name` comes from `firstName()` (:330). It falls back to `name_fallback` or "صديقنا" when the name is shorter than 2 characters, has no letters, or has digits.
- `cleanParam()` (:346) strips the newlines and tabs that Meta rejects.

**Attribution: `results()` (:1213-1285)**

Orders from `started_at` to +7 days, not cancelled, filtered by `coupon_code` if the campaign has one.

**Click tracking**

- Route: `GET /wc/{store}/{token}` (`routes/web.php:784-787`), `where store = g|a|\d`, `throttle:60,1`, **no auth**, declared before the `/{slug}` catch-all.
- Handler: `WhatsAppCampaignClickController@open` (:21-41).
  - The token is lower-cased and stripped to `[a-z0-9]`.
  - `WCU::recordClick()` (:1290-1315) sets `clicked_at` once, increments `clicks`, and bumps the campaign's `clicked_count` on the first click only.
  - The request then redirects with `redirect()->away(WCU::clickTarget())` (:252-267). The target is the template button's admin-set URL, else the Play or App Store URL setting, else `url('/')`.
- **No open redirect:** the destination never comes from the request.
- Tokens are random (12 chars), not signed. Anyone holding a forwarded message can inflate clicks, up to 60/min per IP.
- Targets have no domain allowlist beyond `^https?://` (`WhatsAppTemplateController.php:152-158`).

**Templates: `WhatsAppTemplateController`**

| Action | Line | BuzzBip call |
|---|---|---|
| store / resubmit | :337 | `createAndSubmitTemplate` (service :432) → state PENDING |
| delete | :363 | remote delete; local row marked ARCHIVED |
| recheck | :404 | `templateState` |
| import | :208 | `listTemplates` |

The index auto-rechecks non-final states older than 5 minutes (:37).

### 4.2 Customer segmentation (shared)

- `app/Utility/CustomerSegmentUtility.php` plus `resources/views/backend/marketing/audience/_customer_filters`, `_filters_js`, `_range_links`.
- Used by WhatsApp campaigns (`create.blade.php:188, 240, 426`) and custom push (`backend/notification/custom_notification.blade.php:117, 156, 184`).
- Filters (`apply` :116-219):
  - segment: all / ordered / never / inactive N days, by `orders.date`, non-cancelled;
  - city, join date;
  - purchases (`applyPurchases` :260-340): checkout count via `COALESCE(combined_order_id, id)`, spend as `SUM(grand_total × order_rate_sql)`, categories with their subtree, brands;
  - app user (has a Sanctum token), wishlist, birth month, age, gender.
- `cartQuery` (:376-423) reads `cart_retargets` and **calls `CartRetargetUtility::sync()`** (:384). Merely opening a campaign or notification form rewrites retargeting rows.
- No SQL injection was found: raw fragments use bindings, ids pass through `ids()`, and enums are allow-listed.

### 4.2a Profile completeness, popup audiences, Customer Profiles report (2026-11-09)

- **One rule:** `App\Services\Customers\ProfileCompleteness` (docblock = the rule). Complete = name + gender (male / female) + birthday (not in the current year: the app's untouched picker; 1 January accepted) + a verified phone (`users.phone_verified_at`, `PhoneVerification`) + one address with a governorate and a city; e-mail optional. PHP (`missing()`, `summary()`) and SQL (`applyComplete()`, `applyBirthday()`, `applyAddress()`...) agree, pinned by `ProfileCompletenessTest`.
- **`users.profile_completed_at`** (migration `2026_11_09_000001`, index) = the **first** time the profile was complete, never cleared or moved. Stamped by the `User::saved` / `Address::saved` hooks (`refresh()`), and hourly by `customers:profile-completion` (`stampMissing()`) for query-builder writes. The migration dated the profiles already complete (`backfill()`): the latest of registration, phone confirmation, first address and the first audit giving name / gender / birthday a value. User audits start 2026-09-19, so older dates are approximate. On the local copy: 2,572 of 8,454 customers, in 2.8 s.
- **Marketing filters:** `CustomerSegmentUtility` has `birthday` / `address` (`any|known|unknown`) and `profile` (`any|complete|incomplete`), all read through `ProfileCompleteness`. They are in the shared `_customer_filters` partial, so WhatsApp campaigns, custom notifications and the app popup all have them.
- **App popup audience and button:** `App\Services\Customers\PopupAudience`, columns `app_popups.audience` / `action` (migration `2026_11_09_000002`, NULL = everyone / close only, as before).
  - Viewers: `everyone`, `guests`, or `customers` + the marketing filters. The customers option only shows to active customers (not banned or deactivated).
  - Checked in `app_popup_for_viewer()` for every popup, coupon or general. The audience travels in the payload as `_audience` / `_key` and is removed there, so it is never sent.
  - A customer's answer is cached 10 minutes per popup version. Reaching a complete profile forgets it.
  - The button (`action`) is a link or nothing (`{type: url, url}` or NULL); the profile page is simply the link `/profile`, which the app opens as a deep link. The web popup's button opens it.
  - The audience is set on Marketing → App popup (general popups). A coupon popup made from the coupon screen stays for everyone, limited by its coupon's own rules.
  - **Several popups at once** (migration `2026_11_09_000004`, `app_popups.priority`, at most `AppPopup::MAX_ACTIVE` = 5): each viewer gets the first in staff's order whose audience they are in. `app_popup_payload()` = the first live popup + `_candidates` (all, in order). `app_popup_for_viewer()` returns one and strips the internal keys, so the app's `popup` is unchanged. The website's cached payload translates each candidate's button.
  - Popups page: up / down (`app-popups.move`), per popup "in its audience / get it after the popups above" (`PopupAudience::reach()`, customers only, coupon rules not counted), and "which popup does this customer see?" by phone (`preview()`).
  - Read the switched-on rows with `activeAll()` / `activeForCoupon()`, never `active()` alone. The coupon screen did, and it is now per coupon; the coupon list badges every coupon with a popup on.
  - App contract: `docs/api/app-popup-audience.md`.
- **Account bar:** the website profile page (`frontend/user/profile`, laid out like the app) and `profile/counters` → `profile_completion` (`summary()`).
  - The website form (`HomeController@userProfileUpdate`) no longer writes the phone, the account's own country / state / city, or a password. Before, it wrote the phone as typed, with no check that another account held it.
  - E-mail is a plain optional field, unique, and required for an account without a phone. Before, the "change e-mail" box said a mail was sent and changed nothing.
- **Verified phone** (`users.phone_verified_at`, `App\Services\Customers\PhoneVerification`, migration `2026_11_09_000003`):
  - Customers who had a phone count as verified; those without one do not (580 on the local copy).
  - Afterwards only a WhatsApp code stamps it: `OtpGuard::markVerified()` (every code-accepting path) stamps the account holding the number, and the `User::saving` hook stamps a phone saved while OtpGuard holds its proof (registration, checkout account, adding a phone). A phone saved by admin / staff in the panel counts as verified too (`staffIsWriting()`: add / edit customer, Excel import). A phone changed without a code loses the stamp (`User::saved`, written to the row).
  - No customer changes a phone from the profile. One without a phone adds it with a code: on the website `ProfilePhoneController` (`profile.phone.send|verify|cancel`, a card on the profile page, `09…` read as Syrian); in the app `send-otp` + `verify-otp`, then `profile/update` (same number ignored, a different one or an unproven new one refused; contract `docs/api/app-popup-audience.md` §6). Pinned by `PhoneVerificationTest`.
- **Customer Profiles** (ملفات العملاء, formerly Customer Demographics; `reports.demographics`, `CustomerDemographics`): account only, with no orders or money.
  - The period is by registration or by `profile_completed_at` (`basis`).
  - Shows profile parts, gender, age groups (under 18 is its own group; it used to fall into 55+), governorate, what is missing, per month, and the customers list + CSV. That list, with `basis = completed` and a campaign's dates, is where a draw's winner is picked.
  - A customer's governorate = their default address, else the newest one, through one grouped subquery. A per-row correlated subquery in the join ran for minutes on the local copy.

### 4.3 Cart retargeting

- **There is no `CartLog` model and no `cart_logs` table.** "Cart logs" and the "Cart report" read the live `carts` table (`CartLogController`, 91 lines).
- `CartRetargetController` (610 lines):
  - `index` (:32-88) runs `CartRetargetUtility::sync()`, throttled by a 60 s `Cache::add` lock, then shows filters, KPIs and `?export=csv`.
  - `update` (:90-170):
    - validates `contact_status` (pending / contacted / no_answer / wrong_number) and `cart_status` (active / abandoned / converted / cancelled / closed);
    - `order_value` is entered in SYP and stored as BASE;
    - copies the shared fields to every open cart with the same phone, inside a transaction.
  - `claim` (:176) / `release` (:191) write `cart_retarget_holds` (unique `customer_key`). Admins can take over (`canTakeOver` :229).
  - `customers()` also gives each registered customer's row (2026-10-26) their orders, as the repeat-customer badge of the orders list (`CustomerOrderHistoryUtility`, one grouped query per page: every checkout so far, or those before the converted order once the cart became one), and their join date (`users.created_at`, shown as "Joined 2 days ago"). Guests get neither. Both are in the follow-up dialog and the CSV. Filter "Customer History" (`customer_history` = `new` | `returning`, in `applyScope()` so the KPIs follow it) asks the same question in SQL (`previousOrderSql()`); on the MySQL copy it agreed with the badge on every row.
- `cart_retargets` table:
  - migrations `2026_09_09_102603`, `2026_09_12_000001` (snapshot columns), `2026_09_24_000002` (holds);
  - `cart_key` is `u:<user_id>` or `g:<temp_user_id>`.
- Sync (`CartRetargetUtility.php:77+`):
  - carts go to abandoned after `ABANDON_AFTER_MINUTES` = 60 (:29);
  - conversion lookback is 3 days (:32).
- `GuestCartInfo` (`guest_cart_infos`, schema only in `sqlupdates/feature_guest_cart_infos.sql`, no migration) stores a guest's phone per `temp_user_id`.
  - Web writer: `POST /guest-cart-info` (`routes/web.php:149-152`). Uses the session `temp_user_id`, has CSRF, no throttle.
  - API: `GET` and `POST api/v2/guest-cart-info` (`routes/api.php:275-278`, `Api/V2/GuestCartInfoController.php`). **Unauthenticated; anyone with a `temp_user_id` can read or overwrite the phone** (see §8).
- `carts:send-reminders` is described in §6.

### 4.3a Order rating follow-up (2026-10-26)

Customer service calls customers whose delivered purchase is not rated yet. Admin → Order ratings has two tabs (`backend.order_ratings._tabs`); the follow-up page is `backend/order_ratings/follow_ups/index`. Everything is in `app/Services/OrderRating/FollowUps/` (static classes; the map is `RatingFollowUps`' docblock).

- **Tables** (migration `2026_10_27_000001`):
  - `order_rating_follow_ups`: one **case** per checkout (`combined_order_id` unique). Since 2026-11-11 (migration `2026_11_11_000001`) it also holds the "after rating" complaints, told apart by `kind` (`not_rated` / `low_rating`; `order_rating_id` and `rating` are the complaints' own columns), unique per (`kind`, `combined_order_id`): a checkout may have one case of each kind. See `FollowUpCases`' docblock: every query is limited to one kind (models through `App\Models\Concerns\RowsOfSharedTable`, query-builder queries through `FollowUpCases::notRated()` / `lowRating()`), because the "did not rate" sync closes cases a complaint must never lose. The complaints were copied with ids above every old id and their call history renumbered; `low_rating_follow_ups` is left unread until dropped.
  - `order_rating_follow_up_agents`: one row per **customer** (`customer_key` unique: `u:<user id>`, else `p:<phone>`). This is what stops two agents taking the same customer: of two simultaneous takes, the `insertOrIgnore` lets one in.
  - `order_rating_follow_up_logs`: every call, assignment and automatic change, never overwritten. Since 2026-11-10 (migration `2026_11_10_000004`) it is the call history of **both** tabs, told apart by `kind` (`not_rated` / `low_rating`, `FollowUpLogs`; models `OrderRatingFollowUpLog` / `LowRatingFollowUpLog` are scoped to their kind by `App\Models\Concerns\RowsOfSharedTable`). `follow_up_id` points at a different case table per kind: never query it without the kind. `low_rating_follow_up_logs` was copied in and left in place, unread, to be dropped by a later migration.
- **Cases (`FollowUpSync`).** A case is a checkout that is delivered by the rule of `combined_delivery_status()`, written in SQL (`deliveredCheckouts()`), and has no `rated` `order_ratings` row.
  - Older checkouts with no rating row count too ("Not asked").
  - The sync runs from the page (at most once a minute) and from `order-ratings:follow-ups` (hourly).
  - On the local copy it created 993 cases from 1,002 delivered checkouts.
  - Rating status: Not asked / Waiting / Dismissed ("not now") / Rated.
- **Statuses.**
  - Open: `pending`, `contacted`, `no_answer`.
  - Closed: `declined`, `wrong_number`, `no_response` (set by the agent); `rated`, `cancelled` (set by the system only).
- **Automatic changes.**
  - `rated`: through the listener `UpdateRatingFollowUp` on `OrderRatingSubmitted`, and through the sync. A closed case still turns `rated`. `rated_after_contact` is set when the rating came after the first call.
  - `cancelled`: the checkout left "delivered" (`OrderRatingWithdrawn`, or the sync). The case reopens when the checkout is delivered again.
  - `no_response`: 7 days after the last call while `contacted` / `no_answer`, unless "call again on" is later.
  - A supervisor can reopen anything closed except `rated` and `cancelled`.
- **Assignment (`FollowUpAssignments`).**
  - An agent takes an unassigned customer, or saves a call, which takes them first.
  - A supervisor assigns, bulk-assigns, reassigns ("Reassign" ticked) or unassigns.
  - Next to each customer, on both tabs, a supervisor gets one "Assign" menu (`follow_ups/_assign_menu`, 2026-11-08) that does all of it: "Take this customer", then every agent (moving a customer someone holds: the button reads "Reassign", the current agent is ticked) and "Unassign"; an agent without the supervisor permission keeps the plain "Take this customer" button. One form per customer; each item posts `agent_id` to the assign route, or `customer_key` to take / unassign (`formaction`). The card clips its corners, so the page lets the open menu out (`.orf-customer.is-menu-open`).
  - Only the holder records calls; there is no take-over for admins.
  - The agent is copied onto the customer's open cases (`agent_id`, which the list and the per-agent numbers read), and a new case of that customer joins them.
  - The customer is freed once nothing of theirs is open.
  - A customer whose agent made no call within 48 hours of being assigned **stays with that agent** (since 2026-10-27; before, they went back to unassigned, log `released`). The page shows the agent's badge red with "did not respond" (`FollowUpAssignments::overdueQuery()`, live: a call by that agent since the assignment ends it). The supervisors get one bell notification per agent, **once per assignment** (`order_rating_follow_up_agents.overdue_notified_at`, migration `2026_10_27_000003`; log `overdue`; `OrderRatingFollowUpNotification`, `kind = overdue`, its link opens that agent's customers). A reassignment clears it and starts the 48 hours again. The badge (2026-10-28) is solid red with a warning icon and reads "agent did not call within 48 hours · N hours/days late" plus the assignment date; the filter "Agent follow-up" (`agent_late`: late / on_time, `FollowUpList::applyAgentLate()`) lists them. It is the agent's side only: the customer's side is the follow-up status, whose labels now say "Customer did not answer" / "Customer did not respond".
  - A supervisor assigning someone else sends that agent one notification per batch (`kind = assigned`).
  - Both kinds show in the bell dropdown itself, at the top of the Orders tab under "Follow-up assignments", with the order follow-up alerts: `NotificationUtility::STAFF_ASSIGNMENT_TYPES` feeds `unreadDropdownTabs()` and the 30-second refresh (`orders.staff_notifications`). Holders of the follow-up permissions get the bell even without `view_notifications` (`NotificationUtility::staffAssignmentPermissions()`). Before this, they appeared only under "View All Notifications".
- **Calls (`FollowUpCalls::record`).** Each ticked case gets its own status; the note and "call again on" are shared. Agents never enter a rating: the row copies the link to the customer's order page.
- **The page (`FollowUpList`).**
  - One row per customer: their cases matching the filters, plus every open case of theirs (marked "outside the filters").
  - Each row shows the last contact (any case, any time), the full history, a "Contacted N days ago" badge (within 30 days), and a badge when another agent holds the same phone on the cart retargeting page (`cart_retarget_holds`). The badge blocks nothing.
  - Banned or deactivated customers, and customers with no phone, are never listed.
  - Each row also shows how many of the customer's purchases delivered in the page's date range they rated and their average stars (`ratingHistory()`: delivered checkouts by their last delivery, rated ones included; all time when the range is all). Information only: nothing is blocked.
  - Defaults: delivered in the last 30 days, delivered more than 48 hours ago, open cases, "mine or not assigned" (supervisors: everyone).
  - The numbers (open / unassigned / contacted / rated after contact / conversion, plus the per-agent table) follow the date, age, search and rating filters, but not the status and agent filters.
  - CSV: one row per case.
- **Permissions.**
  - Opening the page and the CSV: **every employee** (the admin route group: admin / staff), read only - a notice says so, the take / record / assign / unassign / reopen controls are hidden and the routes refuse them (decided 2026-10-28). The side menu shows the entry to all; the two tabs show for `view_order_ratings` holders. Read-only viewers start on everyone's customers.
  - `follow_up_order_ratings` (agent): granted to CX, CX manager, CEO and full access.
  - `assign_order_rating_follow_ups` (supervisor): granted to CX manager, CEO and full access (migration `2026_10_27_000002`; the roles edit page lists only permissions the editing account holds, `@can`, so "full access" staff did not see them before).
  - Roles are matched by name, case-insensitively ("Cx Manager" on production). Agents and supervisors are whoever holds the permission through a role or directly; the Super Admin bypass does not count.
- **Deploy:** `php artisan migrate` (tables, permissions, Arabic strings), then `php artisan cache:clear`. The page's first sync creates the cases.

#### Customer notes and "Follow-up after rating" (2026-11-07)

Decided with the user: customers get one agent across both tabs, plus notes of their own. Migration `2026_11_07_000002` (new tables only, no permission).

- **Only the customer is assigned** (decided 2026-11-08, after a first version gave each order row a chip opening the order page's "متابعة الطلب الداخلية" modal): neither tab assigns orders one by one or edits the order's internal notes, and a call's note stays in the call history (`order_rating_follow_up_logs`, kind `not_rated` / `low_rating`). The order page's own follow-up (`order_staff_details`, `order_staff_notes`) is unchanged and is not shown here.
- **Customer notes** (`CustomerNotes`, partial `follow_ups/_customer_notes`; stored in `order_staff_notes` with `notable_type = customer`, `notable_key` = the customer key, since 2026-11-10 - `App\Utility\StaffNotes`, migration `2026_11_10_000005`, which copied the old `order_rating_customer_notes` rows above every old id and moved their audits; the old table is left unread until dropped): notes about the person ("calls after 6 pm"), on both tabs. Agents write them; the writer, a supervisor or the Super Admin edit or soft-delete them. `revision` refuses a stale form, and every change is an `audits` row. Routes `admin.order_ratings.customer_notes.store|update|destroy` (agent permission). The form posts its card's `anchor`, so the page comes back to that card.
- **"Follow-up after rating"** (third tab, `admin.order_ratings.after_rating.index|export|record|reopen`, `LowRatingFollowUpController`, view `after_rating/index`). The cases are purchases rated below `config('order_ratings.alert_below')`, the low-rating email's rule. It is laid out like "did not rate", one card per customer (`LowRatingList`), with the stars, comment and weak criteria under each order.
  - **Cases** (`order_rating_follow_ups` with `kind = low_rating` since 2026-11-11, before that `low_rating_follow_ups`; one per rating, `LowRatingCases::sync()`): created by the `OrderRatingSubmitted` listener and by `FollowUpSync::run()`. Both create the low case **before** the "did not rate" case closes as rated, so a customer's agent stays held across the rating. Ratings from before the tab are backfilled by the first sync. A rating whose order was deleted gets no case.
  - **One agent per customer on both tabs.** The tab shares `order_rating_follow_up_agents` with "did not rate": take / assign / reassign / unassign post to that tab's routes (`customerKey()` accepts a customer of either tab). `FollowUpAssignments::hasOpenCases()` / `applyToOpenCases()` and `FollowUpSync::pruneAssignments()` count both tables, and a call on either tab ends the 48-hour "did not call" flag (`overdueQuery()` reads the calls of both kinds in the one log table). Only the customer's agent records a call; recording on a free customer takes them.
  - **Statuses:** pending → contacted / no_answer (open) → resolved (closed). "Call again on" is shared by one call's orders. There is no automatic closing (a complaint is resolved, not timed out). A supervisor reopens, and the case rejoins whoever holds the customer.
  - **The page:** filters are status, agent (`agent_id`, the copy of the customer's agent), agent late and stars. Sort by newest, oldest, lowest rating or most orders. Numbers: low ratings, not contacted, contacted with the median time to the first call, resolved, plus the per-agent table for supervisors. CSV: one row per case. The tab's badge counts the last 30 days' low ratings nobody called.
  - **Cross-links:** a "did not rate" card shows the customer's open low ratings (`FollowUpList::rows()['low_open']`), and an "after rating" card shows their orders not rated yet.
  - **Who opens it:** `view_order_ratings` or `follow_up_order_ratings` (it shows customers' comments, unlike the "did not rate" tab, which is open to every employee).
- **Tests:** `RatingFollowUpNotesTest` (SQLite; shares its schema with `OrderRatingFollowUpTest` through `Concerns/OrderRatingFollowUpTestSchema`) and a case in the opt-in `OrderRatingFollowUpRuntimeTest`. That class now snapshots the follow-up tables (and the order-note tables, to prove a call leaves them alone) and restores them in `tearDown`: on the local copy they are MyISAM and ignored the rollback, so every run used to leave its takes, calls and notes behind.
- **Deploy:** `php artisan migrate` (tables, Arabic strings), then `php artisan cache:clear`. The first page load or hourly sync creates the cases of past low ratings.

### 4.3b Contact Us requests (2026-11-04)

Contact Us is the one way customers and sellers write to us: **support tickets were removed** (decided with the user 2026-11-04; migration `2026_11_04_000003` backs up the `tickets` / `ticket_replies` rows and the two ticket permissions to `storage/app/backups/support_tickets_<time>.json`, then drops them - `down()` restores; every old ticket URL of the customer, seller and admin panels redirects to `/contact-us` or Admin → Contacts, and the ticket controllers and views were deleted). The map is `App\Services\ContactRequests\ContactRequests`' docblock.

- **Sent from** the website form (`/contact-us`, a CMS page; `ContactController@contact`) and the app (`POST api/v2/contact-us`, contract `docs/api/contact-us.md`; both `throttle:contact-us` in `RouteServiceProvider`: 5 in 10 minutes per account, else per phone typed, and 30 per address), both through `ContactRequests::submit()` with `rules()`: name, phone (required, it is the WhatsApp number), e-mail (optional, `''` when empty: the column is NOT NULL), topic (`TOPICS`), order number, message. A signed-in customer or seller is linked (`contacts.user_id`; on the app the token is optional). No e-mail goes to staff (removed 2026-11-09, ADR 0011): requests are seen and answered on WhatsApp from Admin → Contacts.
- **Linked from** the footer ("Send us a message", desktop and phone), the header menu (Contact Us link), the customer account menu (`CustomerAccountMenu`, where Support Ticket was), the header's account dropdown and the seller panel's side menu.
- **Admin → Support → Contacts** (`Admin\ContactRequestController`, `backend/support/contact/{index,show}`), kept simple on purpose (the user, 2026-11-04: no assignment, no internal notes, no reply text, no spam status): tabs by status (`new`, `closed` shown as "Contacted", all), filters (topic, dates, words or `#id`), a status dropdown on each row saved at once (`partials/whatsapp_script`, JSON to `contacts.status`), ticks for bulk delete; the request page shows the message, the sender's account (linked, else found by phone) with their latest orders and the order they typed, the status buttons and the history (`contact_request_logs`, one row per step, never updated).
- **Permissions** (section `support`): `view_all_contacts` sees everything; `reply_to_contact` taps WhatsApp and moves a request between new and contacted; `delete_contacts` (CX manager, CEO, full access; migration `2026_11_04_000002`) deletes **for good** (the request and its history, no restore; decided 2026-11-04 - migration `2026_11_04_000004` removed the soft delete with the requests it had hidden, and turned requests marked spam back to new).
- **The answer** is a tap on the WhatsApp icon (list and request page, `partials/whatsapp_button` + `whatsapp_script`): it opens the employee's own WhatsApp on the sender's number (`wa.me`; BuzzBip sends only Meta-approved templates) and posts `contacts.whatsapp`, which closes the request with who and when (`replied_at / replied_by / reply_channel = whatsapp`). Nothing is sent from the site. Rows answered by e-mail before 2026-11-04 keep `reply` and `reply_channel = email`.
- **Security** (2026-11-04, after a Burp Suite scan filled the table with SQL-injection payloads): the form validates e-mail and phone, is rate limited, and every customer text is escaped on the staff pages (before, the message was printed raw in the admin window: stored XSS).
- Pinned by `tests/Feature/ContactRequestsTest.php`. **Deploy:** `php artisan migrate` (one `ALTER` on the small `contacts` table, the log table, the permissions, the Arabic strings), then `php artisan cache:clear`. Give CX the `view_all_contacts` + `reply_to_contact` permissions in Staff → Roles if they should answer (only CEO, Cx Manager, Ecommerce, IT, Marketing Manager and Operation Manager held `reply_to_contact` on 2026-11-04).

### 4.4 Loyalty, membership, referrals

**Data model**

All models use `$guarded = []`. No Laravel migrations exist; the schema is **UNVERIFIED**, probably in the base SQL dump.

- **`loyalties`** (`App\Models\Loyalty`) are rule rows: id 1 = referral, 2 = order, 3 = product review (per code comments); columns `status` (label), `number_of_points`, `duration` (months).
- **`user_loyalties`** (`UserLoyalty`) is the points ledger: `user_id`, `loyalty_id`, `points`, `start_date`, `end_date`.
- **`memberships`**: `title`, `from`, `to` (a points range).
- **`register_invitations`**: `user_id` (new user), `added_by` (referrer), `referral_code`.
- **`settings`** row 1 (`App\Models\Setting`): `price`, `points`, i.e. points per amount.

**Earning points** (always a new `UserLoyalty` row, `end_date = today + duration months`)

| Trigger | Where | Notes |
|---|---|---|
| Referral (credited to the referrer) | `Auth/RegisterController.php:156-178`, `HomeController.php:1271-1293`, `Api/V2/AuthController.php:78-99` and :184-195 | Four copies. Each sends an FCM "points earned" push. **In API signup, points are granted before the new user is validated and saved** (:78-99 vs `save()` at :121). No self-referral guard was found. The `RegisterInvitation` row is written at `RegisterController.php:205`, `HomeController.php:1364`, `Api/V2/AuthController.php:126` and :284. |
| Order | Web only: `CheckoutController@order_confirmed` (:1178-1213) | `floor(combined_order.grand_total × points/price)`. `grand_total` is BASE while the setting reads as per SYP (**UNVERIFIED** intent). It runs on a GET page with **no guard, so every reload of the confirmation page awards points again**. Never reversed on cancel. Division by zero if `price` = 0. The app flow has this block commented out (`Api/V2/OrderController.php:582-599`), so **app orders earn no points**. |
| Product review | `ReviewController.php:150-161`, `Api/V2/ReviewController.php:204-216` | Per review. Purchase and one-per-product rules are **UNVERIFIED** (possible farming). |

**Spending points:** none found. Points only decide the membership tier. The CMS `club_points` addon is a separate, unrelated system.

**Membership tier** is computed on the fly, never stored, in two copies (`Api/V2/ProfileController.php:29-37`, `Api/V2/AuthController.php:834-845`). Both sum the valid points (`end_date >= today`), then pick the first membership, ordered by `from`, with `from <= total <= to`. A total that falls in a gap gives `null`. The website shows no points since 2026-10-24 (taken out of the account menu until loyalty is ready; `user.loyalty_logs` still answers by URL).

**Admin pages:** `LoyaltyController` (index hides id 2, :26), `MembershipController` and `SettingController`. Their updates have **no validation** (:54-63, `SettingController.php:32-40`).

**API:** `GET v2/loyalties` and `GET v2/memberships` (`routes/api.php:176-177`) are public. `v2/loyalties` is registered **again** at :184 under Sanctum → `logs()`. The later registration wins, so the public list is shadowed (**UNVERIFIED** at runtime).

---

## 5. Notifications

### 5.1 Channel table

| Channel | Main code | Fired from | On/off switch | Failure handling |
|---|---|---|---|---|
| **DB (Laravel `notifications`)** | Custom channel `app/Notifications/DbNotification.php`; classes `OrderNotification`, `CustomNotification`, `PayoutNotification`, `ShopProductNotification`, `ShopVerificationNotification`, `PreorderNotification` | Order status: `FNT::sendNotification` (:580) or `NU::sendNotification` (:84), from `OrderController:1094` (trait copy), `:1188` (NU), `Seller/OrderController:446`, `PaymeraUtility:158`. Unpaid reminder: `OrderController:1273-1280`. Custom: `CustomNotificationUtility:553`. Payout (`CommissionController:97`, `Seller/SellerWithdrawRequestController:51`), shop product (`ProductController:760`, `Seller/ProductController:151`), shop verification (`SellerController:355/374/409`) — these line numbers UNVERIFIED individually. | `notification_types.status` per type via `get_notification_type('order_{status}_{user_type}')` (`Helpers.php:4466`); toggle `notification-type.update-status` | Until 2026-11-09 `DbNotification` read `Auth::user()->id` and threw without a signed-in user, so the Paymera / eCash callbacks wrote no seller / admin "paid" entry; the value never reached the table anyway (`create()` on the relation writes the type), so the line was removed. Pinned by `OrderStatusNotificationPathsTest`. |
| **FCM push (mobile app)** | `FNT::sendFirebaseNotification` (:29, single) and `sendFirebaseParallelNotifications` (:227, `curl_multi`; since 2026-10-03 one multi run per `FCM_MAX_CONNECTIONS` messages, because with the whole batch on one handle `CURLOPT_TIMEOUT` ran from add time, queue wait included, and ~1500 messages per scheduled batch died with `HTTP 0` after every retry - the log line now carries the curl error when the body is empty). HTTP v1 with a service-account JWT; credentials file at `storage_path('app/kapitano.json')` (:37, :191, :237), never print it. | Seller order status (`Seller/OrderController:394`, :458), seller app (`OrderService:97`, :159), loyalty (`FNT:608`), custom batches (`CustomNotificationUtility:602`), `SendCartReminders:68` (the public `api/v2/test-notification` was removed 2026-10-21). **Admin order status changes push nothing** (commented out, `OrderController:1098-1115`, :1191-1203). | `get_setting('google_firebase') == 1`, checked at call sites (the trait does not check it) | Logged; `UNREGISTERED` tokens removed (`removeInvalidToken` :384); a `firebase_notifications` row only on success. **No cURL timeout**, and synchronous in the request. The single path mints a new OAuth token on every call. |
| **Seller PWA push** | `app/Utility/SellerPushUtility.php` (third FCM implementation) | `SellerOrderAlertUtility::newOrder` | `google_firebase` plus the credentials file (`enabled()` :57) | Deferred to `app()->terminating`, `Http::pool` with a 10 s timeout, idempotent `Cache::add` |
| **Seller WhatsApp order alert** | `SellerWhatsAppUtility` (`newOrder` :150, `send` :588) → `BuzzBipService::sendTemplate` | `SellerOrderAlertUtility::newOrder` (:25), called from `CheckoutController:1171`, `FNT:577`, `NU:81`, `PaymeraUtility:162` | `seller_whatsapp_alerts` setting plus the order template (`seller_whatsapp_order_template` picked on the settings page, else `BUZZBIP_SELLER_ORDER_TEMPLATE_ID`); staff changes go out only when staff send them (`SellerOrderChangeMessages`, template `seller_whatsapp_changes_template`); pilot list `seller_whatsapp_alert_sellers` (empty = all); other `seller_whatsapp_*` settings (`SellerWhatsAppSettingsController.php:31-39`) | Row per order in `seller_order_notifications` (unique `order_id`); cron retries, polls, reminds and escalates; 403 raises `Log::critical` at most once every 30 min (:732) |
| **Custom / scheduled push** | `CustomNotificationUtility` (773 lines): `send()` :520, `sendDue()` :492 | `NotificationController@sendCustomNotification` (:266-387) sends "now" **synchronously in the web request** (`set_time_limit(0)`); scheduled batches go through `notifications:send-scheduled` | Needs `google_firebase`; otherwise every device is recorded as `SKIPPED` / `FIREBASE_DISABLED` | Atomic claim `pending → sending` (:522-531); duplicate guard = sha fingerprint + `Cache::lock` + DB check (:353-368). Any exception sets `FAILED`. Tables: `custom_notification_batches`, `custom_notification_recipients`, `custom_notification_failures`, `firebase_notifications`. **Scheduled and send-now batches write no inbox rows** (`with_inbox=false`), so they never appear in "history" (:685). |
| **Email** (§5.3f, ADR 0011) | `EmailUtility` (verification codes, seller registration to admin, seller payouts), `ForgotPasswordController`, `EmailLowRating`, `NewsletterController`, the two email-verification notifications | Since 2026-11-09 **no order e-mail of any kind** (status, invoice, placed), no welcome / new-customer-to-admin / wallet / refund / auction / chat / delivery-boy / low-stock / Contact Us e-mail | **Global:** `config('mail.sending_enabled')` = env `MAIL_SENDING_ENABLED`, **default false**, enforced by a `MessageSending` listener (`AppServiceProvider`); the low-rating alert alone bypasses it. Transport: `MAIL_DRIVER` (`smtp`, or `onesignal` = `App\Services\Email\OneSignalTransport`). Per-template: `email_templates.status`. | OneSignal: a refusal is logged (`OneSignal email not sent`), never thrown, never retried over SMTP. |
| **SMS** | `SendSMSUtility` (via the `sendSMS()` helper, `Helpers.php:71`); `SendSmsService` is **dead** (imported, never called) | order_placement (`FNT:547`, `NU:53`), delivery/payment status (`OrderController`, `Seller/OrderController:376`, :462, `OrderService:78`, :163) | `addon_is_activated('otp_system')` plus `SmsTemplate::...->first()->status` | **`SmsUtility` and `OTPVerificationController` are missing from the repo**. With the addon active these would throw `\Error`, which `catch (\Exception)` does not catch (**UNVERIFIED**: addon state is in the DB). |
| **WhatsApp OTP** | `OtpSenderService` → BuzzBip or Ishaar (§7.3) | Login, registration, checkout OTP, password reset | `otp_provider` setting, else env `OTP_PROVIDER` (default buzzbip) | Throws; logged |
| **Facebook CAPI** | `app/Services/FacebookConversionApi.php` (`sendPurchase` :12, `sendEvent` :32) | `CartController@addToCart:243` (AddToCart), `CheckoutController@order_confirmed:1141` (Purchase) | **None.** Reads `env('FACEBOOK_PIXEL_ID')` / `env('FACEBOOK_ACCESS_TOKEN')` directly (null once config is cached); the token goes in the URL query | Synchronous, no try/catch, no explicit timeout |
| **GA4 Measurement Protocol** | `app/Services/Ga4Mp.php` (`purchase` :58, `addToCart` :79; `beginCheckout` and `viewItem` unused) | `CartController:231`, `CheckoutController:1152` | Skipped if env `GA4_MEASUREMENT_ID` or `GA4_API_SECRET` is empty (:28) | `Http::timeout(5)`, no try/catch |

### 5.2 Facebook CAPI and GA4 details

**Currency and value**

Both send `currency: 'SYP'`.

| Event | Value sent |
|---|---|
| Purchase | `round(convert_order_price($combined_order->grand_total, $combined_order), 2)`: order-date rate, correct. Items: `convert_order_price($detail->price, $order)` (a line total, labelled `price`). |
| AddToCart | `product_price_to_syp($product->unit_price, $product)`: the base product price, not the variant or discounted price, and not × quantity. The browser pixel payload returned by the same action sends `'value' => $product->unit_price` (RAW) with `'currency' => 'USD'` (`CartController.php:269-271`). |

**Purchase fires once per order LINE**

At `CheckoutController.php:1128-1159`, both calls sit inside:

```php
foreach ($combined_order->orders as $order) { foreach ($order->orderDetails as $detail) { ... } }
```

The braces close with `}}` after the GA4 call. Each call carries the partial `items` / `content_ids` built so far, and the whole block **runs again on every reload** of `order_confirmed`.

**No deduplication with the browser pixel**

- Purchase: the server `event_id` is `purchase_{combined_order.id}` (:1123), while the pixel uses `eventID: '{{ $combined_order->code }}'` (`frontend/order_confirmed.blade.php:278`).
- AddToCart: the server uses `Str::uuid()`, while the pixel gets `atc_{id}_{time}` inside its data rather than as the eventID option (UNVERIFIED how the JS consumes it).

**Coverage**

Mobile-app and Paymera server-side orders send nothing.

### 5.3 Device tokens

- **App user token:** `users.device_token`, a single column. It is set via `POST api/v2/profile/update-device-token` → `DeviceTokenUtility::assignToUser` (moves the token off other users and deletes the guest row).
- **Guest tokens:** `guest_device_tokens`, written by the public `POST api/v2/quest/device-token` (sic).
- **Seller PWA tokens:** `seller_push_tokens`.
- **Cleanup:** `notifications:clean-device-tokens [--force]` (dry run by default) uses `DeviceTokenUtility::findDuplicates()` / `removeDuplicates()`.

### 5.3a FCM topics (2026-10-13)

`App\Services\Push\FcmTopics` (its docblock is the map): topics `all` / `customers` / `guests` / `test`, named `<prefix>_<key>` per server (`services.firebase.topic_prefix`, else APP_URL's host - test and live share the Firebase project, so a shared name would reach live customers). `subscribe()` / `unsubscribe()` call the Instance ID API (`iid.googleapis.com/iid/v1:batchAdd|batchRemove`, 1,000 tokens a request, per-token results, dead = `NOT_FOUND` / `INVALID_ARGUMENT`); `send()` is one FCM v1 message with `topic`, payload as `FcmDeviceSender::buildPayload`. Never throws. Admin → Notifications → "Notification topics" (`admin.notification_topics.*`, permission `manage_notification_topics`) shows the names, warns when APP_URL is not the host the page is opened on (a leading `www.` is ignored on both sides, so `www.kapitano.shop` is the live site; the warning also locks the device-run and big-test buttons), and has a test tool that only reaches the `test` topic; it takes device tokens or phone numbers (a number = that account's `users.device_token`, `NotificationTopicController::devicesFromInput()`), so staff can test without the server terminal. Why: a broadcast to ~8,000 devices one by one lost 25-30% to `NETWORK` errors.

**Membership** (`App\Services\Push\TopicMembership`, switch `push_topics_subscribe`): owner read at sync time (account token → account; guest row → its `user_id` or a guest; else nobody), target topics from it (customer: all+customers; guest: all+guests; seller/staff/banned/deactivated/nobody: none). Triggers: `User` and `GuestDeviceToken` model hooks, plus explicit `touch()` in `DeviceTokenUtility::assignToUser`, `GuestDeviceTokenController@storeOrUpdate`, `PruneStaleDeviceTokens`, `Api/V2/AuthController@logout`. State in `push_topic_devices` (one row per token: topics, synced/failed, attempts, last_error). Behaviour changes: app logout with no session left clears `users.device_token`; a signed-in launch with no token saved assigns the phone to the account. 

**Seeding** (`App\Services\Push\TopicSeeder`): `push:sync-topics [--dry-run|--failed|--yes]` and the admin button (`admin.notification_topics.sync_batch`, 500 a request); passes `users` → `guests` → `known` (rows of tokens no longer in either table, checked per batch with `whereIn` - `users.device_token` has no index); report in `business_settings.push_topics_last_sync`; `--failed` hourly in `Kernel`. Measured on the dev copy: 7,942 devices, dry run 1.8 s / 0 calls, real run 10.8 s / 34 calls, re-run 0 calls. 

**Broadcasting** (`App\Services\Push\TopicBroadcast`): `CustomNotificationUtility::send()` asks `topicFor($audience)`; a whole-topic audience (`wholeTopic()`: customers with every filter at its `CustomerSegmentUtility::fromRequest()` default, guests without dates, nothing picked / pasted / carts / skips) goes through `sendToTopic()` (one `FcmTopics::send`, then `firebase_notifications` rows per customer with a token, the inbox when sent from the form, recipients rows with reason `TOPIC`; batch counts = devices in the topic, `audience.sent_via_topic` = topic name). Refused → device-by-device path. Gates: `push_topics_send`, `push_topics_subscribe`, a finished real `push_topics_last_sync`. Dev copy: 1 message / 3.2 s for 7,940 devices. Pinned by `FcmTopicsTest`, `TopicMembershipTest`, `TopicSeederTest`, `TopicBroadcastTest`, `NotificationScenarioMatrixTest`; report in `docs/reports/notification-topics-test-report.md`.

**Governorate topics (2026-10-14)**: an active customer is also in `gov_<states.id>` for every governorate one of their addresses is in (`TopicMembership::targetFor(..., $stateIds)`, read 1,000 accounts a query by `governoratesOf()`); guests never. `Address::saved` (new, `state_id`/`user_id` changed) / `::deleted` call `TopicMembership::touchUser()` (the account's `device_token` + its signed-in guest rows). The customer filter `state_ids` ("Governorate", `CustomerSegmentUtility`, shared with WhatsApp campaigns; offered: active states of active countries) works on every path. `TopicBroadcast::governorateTopic()`: customers only, no guests, nothing picked, every other filter at its default, 1..5 governorates → key `gov:<ids>`, sent by `FcmTopics::send()` as one message with `condition` (`'<name>' in topics || ...`; one governorate = `topic`), so a customer in two of them gets it once; recipients rows `TOPIC` for the filter's customers, no guest rows. Only after a device run by this code (`push_topics_last_sync.governorates`), so after deploying the admin presses "Put every device in its topics" once more; until then such a notification goes device by device (or through the queue). Migration `2026_10_14_000002`: `push_topic_devices.topics` 255 wide, index `addresses(user_id)` (MySQL only). Topics page lists devices per governorate. Dev copy: 2,321 customers → 15 governorate topics (Damascus 686), 19 in two; Aleppo + Damascus = one message to 794 devices, exactly what the same filter reaches device by device. Pinned by `GovernorateTopicsTest`.

### 5.3b Send queue of filtered custom notifications (2026-10-14)

Every custom notification that is not a whole topic (any filter, picked customers, phone numbers, carts, guest dates, skips) used to push every device inside the admin's request (`send()` → `flush()` 200 at a time), so ~25-30% of a big audience failed with `NETWORK` and a dropped request stopped it half way. With the switch `push_queue_enabled` (Admin → Notifications → "Notification topics", off by default; needs migration `2026_10_14_000001`) `send()` only writes one `custom_notification_recipients` row per device with `status = queued` and its `device_token`, and leaves the batch `sending` (the page returns in ~2 s for 7,940 devices). `php artisan notifications:send-queue` (every minute, `withoutOverlapping(10)`, `--limit`) runs `CustomNotificationUtility::processQueue()`:

- takes at most `config('app.push_queue_per_minute')` (1000) due rows, oldest batch first, claimed by one guarded UPDATE (`locked_until` + `claim`); a crashed run's rows come back after `QUEUE_LOCK_MINUTES` (10), and every chunk is marked before the next one goes, so a crash re-sends at most one chunk;
- pushes them through the same `push()` (`sendFirebaseParallelNotifications`, which still does its 3 quick retries); FCM took it → `sent` + `sent_at`; transient (`isTransientFcmFailure`, or no access token) → `next_attempt_at` +5 / +15 / +60 min, `QUEUE_MAX_ATTEMPTS` 4 in all; dead token or out of tries → `failed` + a `custom_notification_failures` row (the failures page);
- refreshes every queued batch's `sent_count` / `failed_count` from its rows (touches `updated_at`, so `isStuck()` - now "started and not moved for 30 min" - never flags an hour of retries) and finishes it (`sent`) when nothing is queued;
- a stopped batch: the list shows "waiting / to be tried again" and a stop button (`cancelScheduledNotification` also takes a `sending` batch with queued rows → `cancelled`, `stopQueue()` turns the rest into `skipped` / `CANCELLED`); the queue skips the rows of any `cancelled` batch, and `FIREBASE_DISABLED` when Firebase is switched off meanwhile.

Off (or before the migration) = exactly the old path. Whole-topic audiences never reach the queue. Dev copy, 7,940 devices with 30% of first tries failing: 97% delivered (the rest dead tokens / never reachable), no device twice, slowest run 3.4 s, done in 90 minutes (the last retry). Pinned by `CustomNotificationQueueTest` (22 cases; each rule was broken on purpose and caught).

### 5.3c Big test that delivers nothing (2026-10-14)

`App\Services\Push\PushValidation` sends every topic (all / customers / guests + each governorate topic devices are in) and every device token we hold (accounts by id, then guest rows whose token no account holds; a phone two accounts share is checked once) to FCM with `validate_only: true`: Google checks credentials, payload, topic / condition and the token exactly as a real send and delivers nothing. Device messages are built by `FirebaseNotificationTrait::fcmDevicePayload()` (the one real sends use) and sent by `sendFirebaseParallelNotifications($messages, true)`, which then writes no `firebase_notifications` row, removes no dead token and logs nothing per device; `FcmTopics::send(..., true)` likewise. Report (setting `push_validate_last_report`): devices, would receive it, dead (`UNREGISTERED` / `NOT_FOUND` / `SENDER_ID_MISMATCH`), failed + codes, each topic accepted or its error. Admin → "Notification topics" → "Run the big test" (`admin.notification_topics.validate_batch`, 500 devices a request, refused without the service account or on the wrong host) and `php artisan push:validate [--limit]`. Dev copy (fake FCM): 7,942 phones in 18 batches, each once. Pinned by `PushValidationTest`.

### 5.3d Lifecycle push flows - flow A (2026-10-26)

`App\Services\PushFlows` (its docblock, `PushFlows`, is the map) sends the marketing sheet's lifecycle pushes (`Kapitano_Push_Notification_Flows_AR1`) through OneSignal or - for a phone whose app has no OneSignal yet - through the FCM token we already hold. Only **flow A** exists: a device opened the app and never registered. A1 = first open + 2 h inside 09:00-22:00 Damascus (outside: 10:00 of the morning; Friday not before 15:30); A2 = day 1 at 19:30 and at least 20 h after A1 (first allowed slot if A1 was late); A3 = day 3 at 19:30; then the phone is `lifecycle_done_at` (the periodic notifications). The flow ends the moment the phone has an account. Wording is the sheet word for word in `push_flow_templates` (A3's 300/400 and `utm_source=push7` are the sheet's own contradictions, kept; marketing edits the table).

- **Tables:** `push_devices` (one row per phone; the FCM token is *not* copied, it is read from `guest_device_tokens` when a push goes out), `push_flow_runs` (unique `flow, subject_key, cycle`: a phone enters once, ever), `push_flow_messages` (one row per step = the durable log; `idempotency_key` is a v5 UUID of run + step, OneSignal's dedupe key and the `msg` the app sends back), `push_flow_templates`. `push_flow_messages.due_at` is NULLable on purpose: a NOT NULL TIMESTAMP without default gets `ON UPDATE CURRENT_TIMESTAMP` on MariaDB 10.4 and every update would move the due time (pinned in `PushFlowsTemplatesTest`).
- **Truth at send time:** `Conditions` reads the database just before every send (account on the phone: `push_devices.user_id`, `guest_device_tokens.user_id`, or a user whose `device_token` is the phone's; the 20 h gap; the send window re-applied to A1/A2). `PushChannelRouter` picks one channel: notifications off on the phone → nothing; OneSignal subscription + `push_flows_onesignal_enabled` + configured → OneSignal; else the FCM token (needs `google_firebase`). Never both.
- **Entering:** `POST /api/v2/push/device` (`Api\V2\PushDeviceController` → `DeviceRegistry`; the app calls it on every launch) enrols a new phone; `php artisan push-flows:import-legacy` gives the old FCM phones a row (read-only on `guest_device_tokens`, never calls OneSignal, repeatable); `push-flows:scan` enrols the rest and ends the runs of phones that now have an account. An FCM row written in the last 30 minutes is the same launch (a new phone), an older one a phone from before (`cohort = legacy`, day 0 = the day it is let in).
- **Safe by default:** `push_flows_enabled` OFF, `push_flows_onesignal_enabled` OFF, `push_flows_rollout_percent` 0 and an empty `push_flows_allowlist` (nobody enters), `push_flows_per_minute` 300. Settings: `php artisan push-flows:settings`. State: `push-flows:status`, `push-flows:migration-report`. Heartbeat of the two scheduled commands is in the cache (`push_flows_last_run_at` / `_scan_at`), not in `business_settings`, so it does not drop the settings cache every minute.
- **Web push on the storefront** (switch `push_flows_web_enabled`, off by default): `WebPush::forUser()` + `frontend/partials/onesignal_web.blade.php` (included at the end of `frontend/layouts/app.blade.php`) load OneSignal's browser SDK, link a signed-in customer as `u_<id>` (and unlink at site sign-out), and ask the browser permission only after an add to the cart or on the second page (once a week at most). Worker: route `onesignal.worker` (`/OneSignalSDKWorker.js`, `OneSignalWebController`). No database writes, no flow for web visitors; seller PWA worker (`/seller/` scope) untouched. `PushFlowsWebPushTest`.
- **Not touched:** FCM topics, the custom-notification queue, every existing push. The app keeps calling `quest/device-token` and `profile/update-device-token`. A new push path must go through `PushChannelRouter`, never straight to FCM.
- **Tests:** `PushSendWindowTest`, `PushFlowsTemplatesTest`, `PushDeviceEndpointTest`, `PushFlowsImportLegacyTest`, `PushChannelRouterTest`, `PushFlowAEnrollmentTest`, `PushFlowASenderTest`, `PushFlowAScenarioTest`, `PushFlowATimezoneTest` (the app zone in Tokyo), `PushFlowsTestSendTest` (`push-flows:test-send`: one real `[TEST]` push to one named phone over FCM / OneSignal / both, to try credentials and the coexistence of the two SDKs; writes nothing, deletes no token). App contract: `docs/api/push-flows.md`.
- **OneSignal tags** (`OneSignalTags`, switch `push_flows_tags_enabled`): `user_type`, `governorate` and `city` (their NAMES, from the default address, the governorate being the city's own) and `gender` (male / female, only when stated) per customer (`u_<id>`), `user_type = guest` per guest phone (`d_<temp_user_id>`), for segments in the OneSignal dashboard only. Change together: the `User`, `Address` and `DeviceRegistry` hooks, `OneSignalTags::locationsOf()` (names; the FCM topics keep `TopicMembership::governoratesOf()`, ids), `OneSignalClient::updateTags()`, table `push_onesignal_tags`.
- **Admin page** (2026-10-30, `Admin\PushFlowController`, views `backend/push_flows/`, routes `admin.push_flows.*` in `routes/admin.php`, side menu under Marketing → Notification; permissions `view_push_flows` / `manage_push_flows`, migration `2026_10_30_000001`): the switches, rollout, flow B (start date, welcome code, category of the week) and flow C (coupon numbers) settings, the wording of all ten steps and the "what was sent" log, with a health light from the two heartbeats. Same `business_settings` as `push-flows:settings`. The general form validates everything and writes in one transaction (a refusal = a red banner, nothing stored); the Control tab's System check (`SystemCheck`) shows what the web process sees (OneSignal credentials, loaded config vs the config file, Firebase service-account file, heartbeats). Change together: `PushFlowController::STEP_VARIABLES` ↔ `FlowVariables::for()` / `Sender` (a variable a step may use), `config/push_flows.php` defaults ↔ the form fields. Test: `PushFlowAdminTest`.
- **OneSignal templates** (`OneSignalTemplates`, `push_flow_templates.onesignal_template_id`, command `push-flows:onesignal-templates`): a step can be sent as a OneSignal dashboard template to OneSignal phones; FCM phones and steps without an id keep our wording; a refused template falls back to our wording. Change together: `OneSignalDelivery::payload()` (no headings/contents with a template), `Sender::settle()` (the copy of what was sent), `OneSignalClient::templates()/template()`.
- **Flows B and C** (`UserConditions`, `FlowVariables`, `UserDelivery`, `CartSnapshot`, `Incentives`, `PushFlowMoney`; `Enroller::scanB/scanC/enrollUser/enrollGuestCart`; tables: `push_flow_incentives`; settings `push_flow_b_enabled`, `push_flow_c_enabled`, `push_flows_since`, `push_flows_welcome_coupon_code`, `push_flows_weekly_category`, `push_flows_min_cart_syp`, `push_flows_cart_coupon_syp`, `push_flows_cart_coupon_hours`, `push_flows_cart_coupon_batch`, `push_flows_frequent_cancel_*`, `push_flows_c_cooldown_days`): B = registered customer without an order (B1-B4, day-based); C = abandoned cart (C1-C3, 2 h / +24 h / +24 h with a coupon code in C3 shared by a batch of 1000 customers, each customer once and for their own 24 h; the name opens the titles of B3, B4, C1-C3 through `{{name_opt}}`). Change together: `config/push_flows.php` (steps, `after_step`, `coupon`), `Templates::defaults()` (+ a seeding migration for new rows), `Sender::plan()` (conditions -> template -> channel -> variables -> coupon -> render), `Conditions::evaluate()` (C always goes to `UserConditions`), `CouponUtility` / `CompensationCouponUtility` (the coupon rules it copies), the cart pricing helpers (`cart_product_price`/`cart_product_tax`, used for the minimum), and `push:abandoned-guest-carts` (the older guest cart reminder C duplicates).

### 5.3e Staff bell alerts as browser push (2026-11-05)

`App\Services\Push\StaffWebPush` (its docblock is the map). Every new entry of the admin bell (a `notifications` row of a user whose `user_type` is admin or staff: orders, follow-up assignments and notes, online payment alerts, seller / product requests, payouts, preorders) is also a OneSignal web push to the browsers where that staff member turned it on, even with the admin tab closed. A click opens `admin.notification.read-and-redirect` (marks it read, same page as the bell).

- **Switch:** `staff_web_push_enabled` (Admin → Push flows, general form, off by default, refused while OneSignal is not configured). Same OneSignal app and the same worker (`/OneSignalSDKWorker.js`, scope `/`) as the storefront's web push, so a browser has one subscription.
- **Opt-in per browser:** the bell dropdown's last row (`backend/partials/staff_web_push`, included in `backend/inc/admin_nav`) for staff who see the bell; "Turn on notifications" asks the permission and posts the browser's subscription id (`admin.staff_push.subscribe` / `.unsubscribe`, `Admin\StaffPushController`). Table `staff_push_subscriptions` (migration `2026_11_05_000003`): one row per subscription id, owned by the last staff member who registered it; signing out (Logout listener) deletes the ones that session registered.
- **Sending:** `DatabaseNotification::created` (AppServiceProvider) → `noticed()` keeps the id only when the user has a subscription (one memoized query per request), after the commit; flushed at the end of the request and after each queue job, sent through `after_response()`. One OneSignal request per entry (`include_subscription_ids`, never an external id: the browser may carry a customer's `u_<id>`), idempotency key = the notification's uuid. Read entries and users who are no longer staff (or banned) are skipped; dead subscriptions OneSignal reports (`OneSignalResult::invalidSubscriptions`) are deleted.
- **Words:** `App\Utility\NotificationText::plain()`, in the store's default language. Change together with the bell's two components (`components/unread_notification`, `components/notification`) and `Api/V2 NotificationCollection` when a notification type gets a new placeholder.
- A bell entry written with the query builder (none today) would not be pushed.
- **Test:** `StaffWebPushTest`.

### 5.3f Email (2026-11-09, ADR 0011)

**What is sent**, all through Laravel Mail (so `MAIL_DRIVER` and `MAIL_SENDING_ENABLED` apply): email verification codes (web register, admin-created seller, the seller registration code, the app's `AppEmailVerificationNotification`), forgot password by email, the seller registration notice to the admin, seller payout and withdraw-request mails, the low-rating alert (setting `order_rating_alert_emails`, Order ratings page), the admin newsletter and the admin test mail. `EmailUtility::SENT_TEMPLATES` lists the `email_templates` rows something still sends; Admin → Email Templates shows only those (the other rows stay in the table).

**Removed** (decided with the user 2026-11-09): every order e-mail (`EmailUtility::order_email` and its 8 callers - it could also throw outside its `try` on a guest order or a seller without a shop, cutting a status change in half), the invoice e-mail of both `sendOrderPlacedNotification` copies (the `NotificationUtility` one never sent: `->to()` for `->send()`), the welcome e-mails and the new-customer notice to the admin, wallet recharge (and the public debug route `/wallet_payment_email_test`), refund, delivery-boy assigned / registered, auction outbid, chat / conversation, Contact Us to staff (the user, 2026-11-09: every request is handled on WhatsApp; the view `emails/contact` was deleted, the `contact_us_email_to_admin` row stays), and the low-stock e-mail (raw PHP `mail()` to three fixed addresses, one a personal Gmail; its only caller had no route). The mailables `AuctionBidMailManager`, `ConversationMailManager`, `GuestAccountOpeningMailManager`, `InvoiceEmailManager`, `SecondEmailVerifyMailManager` and the notification `PasswordResetRequest` were deleted. Customers learn about their orders by push and the inbox, sellers and staff in their panels.

**OneSignal transport** (`MAIL_DRIVER=onesignal`, also selectable on Admin → Setup → SMTP settings, which then hides the SMTP-only fields): one `POST /notifications` per message with `include_email_tokens` (To + Cc + Bcc, each once, 2,000 a request), `email_subject`, `email_body` (the HTML; plain text is escaped), `email_from_address` / `_name` (the mailable's, else `mail.from.*`). **Transactional by default**: `include_unsubscribed: true`, `disable_email_click_tracking: true`, no unsubscribe link. A mailable is marketing when it carries the header `OneSignalTransport::MARKETING_HEADER` (only the newsletter, `EmailManager` with `'marketing' => true`): unsubscribed addresses are skipped and `[unsubscribe_url]` is added. OneSignal creates an email subscription for each new address. Attachments are not supported (dropped, logged). Uses the push app's keys (`services.onesignal.*`); the sending domain must be verified in the OneSignal dashboard. Pinned by `OneSignalEmailTransportTest`.

### 5.4 Duplicated order-notification helpers

Two copies have already drifted apart (their invoice e-mail was removed on 2026-11-09):

1. `FNT::sendOrderPlacedNotification` (:511) / `FNT::sendNotification` (:580).
2. `NU::sendOrderPlacedNotification` (:21) / `NU::sendNotification` (:84).

Differences:

- NU had the `send` typo (the invoice email never sent); both invoice blocks are gone since 2026-11-09.
- NU has `sendNotification($order, 'placed')` commented out (:63).
- NU overrides `sendFirebaseNotification` with a **legacy** FCM call: `Authorization: key=` + `FCM_SERVER_KEY` against the v1 URL, project id hard-coded, SSL verify off (:109-149). It always fails. It is reached only when `$request != null`, which no current caller passes.

Web COD/wallet checkout has `self::sendOrderPlacedNotification($order)` commented out (`CheckoutController.php:1174`). So web orders send **no** "order placed" email, DB notification or push, only the seller alert.

---

## 6. Scheduler

Schedule: `app/Console/Kernel.php:29-60`. All commands are auto-loaded via `load(__DIR__.'/Commands')` (:68); `SendCartReminders` and `Sent` are also listed in `$commands` (redundant). `routes/console.php` only has `inspire`.

Cron requirement: a server cron line running `php artisan schedule:run` every minute. It is not in the repo (**UNVERIFIED** on production). There is no queue worker (`QUEUE_CONNECTION=sync` per agent notes, UNVERIFIED).

| Command | Schedule | What it does | File:line |
|---|---|---|---|
| `notifications:send-scheduled` | every minute, `withoutOverlapping(60)` | `CustomNotificationUtility::sendDue()`: pending batches with `scheduled_at <= now`, atomically claimed | `Commands/Sent.php:15, 30`; `Kernel.php:37` |
| `notifications:send-queue` | every minute, `withoutOverlapping(10)` | `CustomNotificationUtility::processQueue()`: queued devices of filtered custom notifications, retries, finishing (§5.3b) | `Commands/SendNotificationQueue.php` |
| `push-flows:run` | every minute, `withoutOverlapping(10)` | `Sender::processDue()`: sends the lifecycle flow messages that are due (flow A, §5.3d). No-op while `push_flows_enabled` is off. `--limit`, `--dry-run [--now="Y-m-d H:i" Damascus]` | `PushFlowsRun.php` |
| `push-flows:scan` | every 5 min, `withoutOverlapping(10)` | `Enroller::scanA()`: enrols phones, ends the runs of phones that now have an account, completes finished runs (§5.3d) | `PushFlowsScan.php` |
| `push-flows:sync-tags --failed` | hourly, `withoutOverlapping(30)` | `OneSignalTags::sync()` for the ids whose last push failed or OneSignal did not know yet (max 5 tries). No-op while `push_flows_tags_enabled` is off. Without `--failed`: the first fill of every phone with a OneSignal subscription; `--dry-run`, `--limit` | `PushFlowsSyncTags.php` |
| `search:aggregate-events` | every 10 min, no overlap | `search_events` → `search_queries` and click counts (`--backfill`, `--chunk=2000`) | `AggregateSearchEvents.php:12` |
| `search:recompute-scores` | hourly | Time-decayed scores on `search_queries` and products/categories/brands (`--days=30`) | `RecomputeSearchScores.php:10` |
| `paymera:expire-app-checkouts` | every 5 min | Pending app checkouts older than 30 min are re-checked at Paymera, then expired (never voided remotely) | `ExpirePaymeraAppCheckouts.php:20, 26-48` |
| `seller-whatsapp:process` | every minute, `withoutOverlapping(10)` | Exits if disabled. Passes: retry (< `MAX_SEND_ATTEMPTS`, within 1 day, :70), poll delivery (every 5 min, :104), remind (business-minute thresholds, :143), close dead alerts (:196), escalate (:243). `--limit`, `--dry-run`. | `ProcessSellerWhatsAppAlerts.php:30-64` |
| `order-ratings:follow-ups` | hourly, `withoutOverlapping(30)` | `FollowUpSync::run(true)`: new cases; rated, no-longer-delivered and quiet cases closed; agents who did not call a customer within 48 h flagged, supervisors told once; "after rating" cases of ratings below 5 stars (`LowRatingCases::sync`) (§4.3a) | `SyncOrderRatingFollowUps.php` |
| `whatsapp-campaigns:process` | every minute, `withoutOverlapping(30)` | `WCU::process()`: prepare → send batch → poll (§4.1) | `ProcessWhatsAppCampaigns.php:18, 26` |
| `partners:sync` | daily 04:00, `withoutOverlapping(60)` | Partner sources with `is_active` and `auto_sync` (see the partner-stores doc) | `SyncPartnerStores.php:20` |
| `carts:send-reminders` | **not scheduled** (commented out, `Kernel.php:33-34`); the public `GET api/v2/send-cart-reminders` that ran it was removed 2026-10-21 | FCM to every user with a cart older than 15 min. The "already sent" guard is an empty comment (:55-56). | `SendCartReminders.php:22` |
| `notifications:clean-device-tokens` | manual | §5.3 | `CleanDuplicateDeviceTokens.php:16` |
| `report:backfill-order-coupons` | manual (`--dry-run`, `--window=180`) | Fills `orders.coupon_id` / `coupon_code` from matching `coupon_usages` rows | `BackfillOrderCoupons.php:23` |
| `phones:fix-sellers` | manual | Normalizes `users.phone` to `+<dial><national>` for OTP login | `FixSellerPhones.php:24` |

Scheduled commands run in CLI, where `$_SERVER['HTTP_HOST']` is missing. `getBaseURL()` and similar code build wrong URLs there (CLAUDE.md). Campaign links avoid this through the hard-coded `CLICK_BASE`.

---

## 7. "Change together" list

### 7.1 Report definitions

These are copied logic. Fix all or none.

| Concept | Implementations |
|---|---|
| Revenue per line | RFS `price + tax` × order rate (top_categories, investor, growth, dashboard categories); `price × quantity` (mostSold/leastSold, `AdminController:380`), which is wrong; `price` only, BASE (salesProfit and its export); `grand_total` at order level |
| "Valid order" | RFS default excludes cancelled; dashboard and earning count delivered only; salesProfit, most/least sold, repeat purchase, order time and coupon report include cancelled; aov uses paid and not cancelled |
| Top customers | `top_customers`, `TopCustomersExport`, dashboard `top_customers` |
| AOV | `aovReport` (per order), `item_top_sales`, PMS (per checkout), `top_customer_details` (per checkout), salesProfit (BASE, cancelled included) |
| Repeat rate | `repeatPurchaseRate` (same month), top_customers summary, PMS |
| Filters outside RFS | RC:73-2513 (every pre-RFS report); `SalesProfitReportExport` (full copy); `CouponUsageReportService::filters/dateBounds/queryString` (near-copy, no `exclude_cancelled`); `CartLogController@index2` + `CartReportExport`; commission and wallet `date_range`; Earning report intervals; `SellerResponseReportController::rows()` |
| Date column | `created_at` (skewed) versus `orders.date`. Fix in RFS (`$epochDate`), then roll out to every caller. |
| Lifetime `num_of_sale` as sales | `wish_report` CSV and sort, `profitReport`, dashboard `cached_graph_data` |
| Order rate, payable total, Syria day in SQL | `sqlupdates/kapitano_reports_readonly_access_2026-10-04.sql` views copy `exchange_rate_at()`, `combined_order_payable_total()`, `raw_to_base_sql()`; pinned by `ReportingViewsRuntimeTest` (§3.5). Also any column rename the views read. |

### 7.2 Notifications

- `FNT::sendOrderPlacedNotification` / `sendNotification` ↔ `NU::` copies (§5.4).
- The FCM push blocks copied into `Seller/OrderController:384-395`, :447-459 and `OrderService:87-98`, :149-160.
- Three FCM senders: FNT, `SellerPushUtility`, and the broken NU override. The lifecycle flows add no fourth: `PushFlows\LegacyFcmDelivery` calls `FcmDeviceSender` (as `DevicePushService` does), and `PushFlows\OneSignalClient` is a separate channel; `push:abandoned-guest-carts` (guest cart FCM push) will duplicate flow C when that is built - stop it then.
- Membership-tier calculation: `Api/V2/ProfileController:29-37` ↔ `Api/V2/AuthController:834-845`.
- The referral award (four copies, §4.4) and the `RegisterInvitation` writes (four copies).
- SMS: `SendSMSUtility` ↔ `SendSmsService` (dead).

### 7.3 WhatsApp / BuzzBip / Ishaar sender copies

| # | Location | Provider / auth | Phone handling | Status |
|---|---|---|---|---|
| A | `app/Services/BuzzBipService.php`: `sendOtp` :108, `sendTemplate` :190, `messageState(s)` :289 / :359, template CRUD :432-601, `uploadMedia` :512 | BuzzBip `services.buzzbip.base_url`; JWT from `/api/login_check`, cached as `buzzbip_jwt`, one retry on 401 (:631, :653) | Digits only, plus `resolveCountryCode` (the country of a number posted without one: `PhoneCountries::isoForNumber()`, every country) | Canonical |
| B | `app/Services/IshaarService.php` (POST `{base}/messages`, :94-101) | Ishaar; base URL / key / template / number from business settings `ishaar_*`, else `config('services.ishaar.*')` (default `ishaar.tech/api`); `x-api-key` | `+` + digits (:144) | Live (OTP provider option) |
| C | `app/Services/OtpSenderService.php` (:24-63) | Dispatcher: `otp_provider` → B or A | via A/B | Live. Callers: `Api/V2/AuthController:149, 408, 526`; `PasswordResetController:37, 104`; `ForgotPasswordController:93`; `RegisterController:100`; `CheckoutController:161`; `ShopController:231`; `HomeController:1207, 2245`; `BusinessSettingsController:135` |
| D | `app/Services/OtpService.php` (:26-70) + `WhatsAppOtpController` (`/auth/whatsapp/send-otp`, 3 per phone per 60 s) | via C | digits | Live; `random_int`, hashed in Cache |
| E | `sendCodeForWhatsApp` wrappers: `HomeController.php:1204-1219`, `Api/V2/AuthController.php:401-420` | via C | via C | Live; the OTP is made with `rand()` and stored in plain text in TempAuth |
| F | `IshaarOtpController` (:25-60), `POST /otp/whatsapp/send` (`web.php:126`) | B directly, **ignoring `otp_provider`** | Caller-supplied phone **and code** | Public, CSRF only, no throttle |
| G | Closure `GET /test/whatsapp-otp` (`web.php:130-144`) → F | via B | `?phone=` (default a hard-coded Syrian number) | **Public, no auth, no CSRF, no throttle** |
| H | Hard-coded Ishaar copies: `Api/V2/OrderController.php:714-746` (`sendCodeForWhatsApp`), :752-785 (`sendOrderStatusForWhatsApp`); `OrderController.php:931-977` | `https://ishaar.net/api/messages` (a different host from B), **API key hard-coded `Lr4A***`** (:734, :773, :965) | `$phone` ignored; recipient hard-coded | Dead (call sites commented out), but **the key is in git and must be rotated** |
| I | `app/Utility/SellerWhatsAppUtility.php` (send :588/:600, reminder :245, poll :284) | via A | `resolvePhone` (:522-554) → `compose_e164_phone`, must match `^\+\d{11,15}$` | Live |
| J | `SellerWhatsAppSettingsController.php:136` (test), :174 (template state) | via A | same as I | Live |
| K | Campaigns (`WCU::sendBatch`) | via A | `normalizePhone` (:355) → `compose_e164_phone(…,'963')`, `^\+\d{9,15}$` | Live |

Not senders:

- `CartRetarget::whatsappUrl` (`app/Models/CartRetarget.php:148-160`) builds a manual `wa.me` link;
- the `whatsappChat` setting is a chat widget.

Phone normalizers (at least six; a phone-format change must touch all of them): `normalize_otp_phone` (`Helpers.php:5292`); `compose_e164_phone` (`Helpers.php:5331`), whose dial list is duplicated in `BuzzBipService.php:687-696`; BuzzBip `resolveCountryCode`; `IshaarService` :144; `GuestCartInfoController::formatPhone` (web :68-98, plus an `Api/V2` copy); `CartRetargetUtility.php:506`.

### 7.4 Permissions and settings

A new admin page needs all of these:

- a controller/route `permission:`;
- a migration that seeds the permission;
- a sidebar `@can` and an entry in the group `@canany` list (`admin_sidenav.blade.php:~1303` reports, `~1589` marketing);
- a line in `docs/permissions-reference.md`.

Settings:

- They are read through `get_setting()`, which caches **all** of `business_settings` under key `business_settings` for 86,400 s (`Helpers.php:2959-2972`).
- Writers call `Artisan::call('cache:clear')`, which wipes the whole cache (the app-settings page forgets `business_settings` only, since 2026-10-12).
- A direct DB edit needs `cache:clear`.

---

## 8. Gotchas, known bugs, security issues

Ordered by severity. All were read in code; nothing was exploited.

| # | Issue | Where | Notes |
|---|---|---|---|
| 1 | **Unauthenticated CMS updater (critical, likely RCE)** | `POST /update`, `GET /update/step1-3`, `POST /purchase_code` (`routes/admin.php:~139-145`); `UpdateController.php:25-66`, `:99-125` | Outside any auth group; no constructor middleware. `step0` extracts the upload given by `update_zip` into `base_path()`. Any logged-in user can create a `.zip` upload via `POST /aiz-uploader/upload` (`web.php:228`, `AizUploadController.php:98, 268`). `step2` runs `DB::unprepared` on `sqlupdates/*.sql`. Fix: `auth` + `admin` + permission, or delete. |
| 2 | **Public debug/ops routes (no auth)** — removed 2026-10-21 (`RemovedOpsRoutesTest`) | `routes/api.php` | `POST api/v2/test-notification` (:22-70) pushes any title/text to any token (a real token is the default). `GET api/v2/send-cart-reminders` (:~71) runs `carts:send-reminders`. `GET api/v2/send-notiii` runs `optimize:clear`. `POST api/v2/command-php` (:~115) runs `composer require` via `Process`. `GET /cancel-reason` calls `dd()`. |
| 3 | **Public WhatsApp send endpoints** | `GET /test/whatsapp-otp`, `POST /otp/whatsapp/send` (§7.3 F/G) | Anyone can send WhatsApp messages to any number at the store's cost. |
| 4 | **Hard-coded Ishaar API key** | three places (§7.3 H) | Rotate it. |
| 5 | **Guest-cart-info IDOR** | `GET/POST api/v2/guest-cart-info` | Unauthenticated. Anyone holding a `temp_user_id` (`bin2hex(random_bytes(10))`) can read or overwrite a guest phone, which then feeds cart retargeting and campaign audiences. |
| 6 | Staff-level SQL injection | `interval_type` in the dashboard AJAX blocks (§3.3) | |
| 7 | `.env` writer and settings writers | `env_key_update` (`BusinessSettingsController.php:404-437`); `update` (:473); `updateActivationSettings` (:532) | `env_key_update` writes any key; embedded newlines are not stripped (UNVERIFIED). `update` and `updateActivationSettings` create any `business_settings` type; the latter can run `artisan down`. Treat `update_settings` as super-admin. |
| 8 | Missing permission checks | `/admin` dashboard data and its AJAX blocks; `NotificationController@adminIndex`, `bulkDeleteAdmin`, `readAndRedirect` | Customer `POST /notifications/bulk-delete` (`web.php:539`, controller :707) deletes by UUID with no ownership check. |
| 9 | Public product list | `/admin/home-category-products/{id}` (`admin.php:~103`) | Low risk. |
| 10 | Loyalty | §4.4 | Points re-awarded on every `order_confirmed` reload; referral points granted before API signup succeeds; no form validation. |
| 11 | Tracking | §5.2 | Purchase sent once per order line and again on every reload; no dedup with the pixel; pixel reports USD while the server reports SYP. |
| 12 | Notifications | §5 | NU invoice email never sent; NU legacy FCM override always fails; web checkout sends no "order placed" notification; scheduled pushes write no inbox rows; the trait's FCM calls have no timeout. |
| 13 | Report correctness | §3.3 | Quantity double-count, variant fan-out, fake "cost", Earning braces, BASE printed as SYP. |
| 14 | WhatsApp campaigns | §4.1 | Stuck in `preparing` after a killed process; opt-out not re-checked at send time; CSV formula injection in the export; `CLICK_BASE` hard-coded to production. |
| 15 | Routing oddities | `admin.php` | `orders.admin_cancel` is really `/admin/admin/orders/{id}/cancel`. `custom-notifications.delete/{identifier}` has a literal dot in the path; keep it, the JS uses the route names. |
| 16 | `SendCartReminders` | §6 | No dedup. |

---

## 9. Tests covering this domain

SQLite tests are safe anywhere. Tests marked "live" need `KAPITANO_RUNTIME_DB_TESTS=1` and a local MySQL, and roll back.

| Test (method count) | Covers |
|---|---|
| `Feature/AdminReportsRuntimeTest` (12, live) | Every RFS report: defaults, all filters, CSV |
| `Feature/CouponUsageReportTest` (16) | Coupon report aggregation |
| `Feature/PlatformMetricsServiceTest` (8) | Investor and Growth period maths |
| `Feature/SellerResponseReportTest` (22) | Seller response report, its order columns, the seller panel, the old links |
| `Feature/SellerOrdersReportTest` (9) | Seller orders: every state, cancellation reasons held or not against the seller, lines, alerts, rates, filters, sorting |
| `Feature/SellerOrdersReportPermissionMigrationTest` (2) | Its permission is granted to whoever held `seller_response_report` |
| `Feature/SellerInventoryActivityTest` (17), `Feature/SellerInventoryReportPermissionMigrationTest` (2) | Seller inventory updates: what the hooks record (seller, app, staff) and never record (orders, quietly(), admins, other sellers, console), the history copied once from the audit log, the report unchanged after the audit log is emptied, a product save counted once, regular = half the weeks, idle / never, the record start, partner stores, stale products, orders refused as out of stock, summary, status filter, CSV, paging; the permission migration. `AdminReportsRuntimeTest` renders the page, its CSV and the seller page on MySQL |
| `Feature/StockZeroingTest` (10), `CancelledOrderTotalsTest` (6) + `CancelledOrderTotalsRuntimeTest` | Stock zeroing; cancelled totals |
| `Feature/ExchangeTypeRuntimeTest` (live) | Admin report pages under the price contract |
| `Feature/WhatsAppCampaignTest` (43), `Unit/BuzzBipTemplateSendTest` (9) | Campaign audience, params, sending; BuzzBip template send and state |
| `Feature/SellerWhatsAppAlertTest` (16), `SellerWhatsAppScheduleTest` (12), `SellerWhatsAppSettingsTest` (9) | Seller alerts and `seller-whatsapp:process` |
| `Feature/SellerPushNotificationTest` (10), `SellerPushTest` | Seller PWA push |
| `Feature/CustomNotificationAudienceTest` (12), `AdminCustomNotificationRecipientsTest` (14) | Push audience and recipients |
| `Feature/DuplicateDeviceTokenCleanupTest` (6) | Device-token cleanup |
| `Feature/NotificationQueryCountTest` (8), `NotificationDropdownRuntimeTest` (3, live) | Header dropdown |
| `Feature/EmailSendingSwitchTest` (5) | `mail.sending_enabled` |
| `Feature/AdminNewsletterRecipientsTest` (5) | Newsletter recipient picker |
| `Feature/CartRetargetSyncTest` (13), `CartRetargetingRuntimeTest` (10, live) | Cart retargeting |
| `Feature/OrderRatingFollowUpTest` (25), `RatingFollowUpNotesTest` (customer notes, no per-order follow-up, after rating), `OrderRatingFollowUpRuntimeTest` (5, live: pages, take/call/reassign, 48 h "did not respond" on MariaDB, after rating; restores its MyISAM tables) | Order rating follow-up (§4.3a) |

**Not covered:**

- the pre-RFS `ReportController` methods;
- `EarningReportController`;
- the dashboard;
- exports other than coupons;
- `BusinessSettingsController`;
- `UpdateController`;
- loyalty, membership and referrals;
- `SendCartReminders`;
- FB CAPI and GA4;
- `FNT` / `NU` order notifications;
- email template builders.

---

## 10. Open questions

1. Are the `UpdateController` routes, `api/v2/command-php`, `api/v2/test-notification` and `/test/whatsapp-otp` reachable on production? Is anything in front of them (web-server rules, WAF)? Fix first.
2. Has the leaked Ishaar key been rotated? Is Ishaar still used?
3. Loyalty intent: should order points be based on SYP or BASE, awarded at delivery, and reversed on cancel? Should app orders earn points?
4. Is the `otp_system` addon active in production? If so, the missing `SmsUtility` / `OTPVerificationController` classes will 500 on status changes.
5. Is `MAIL_SENDING_ENABLED` true in production? If false, every email feature (newsletter, templates, order emails) is silently off.
6. Should reports move to `orders.date` (`$epochDate=true`) and a single "valid order" definition? This needs a product decision, since the numbers will change.
7. Is the production cron for `schedule:run` in place, and with which PHP binary and user? Is a queue worker expected anywhere?
8. Where do the base schemas of `loyalties`, `user_loyalties`, `memberships`, `register_invitations`, `notification_types`, `email_templates` and `firebase_notifications` live? They are not in migrations; probably in the original SQL dump or `sqlupdates/v50.sql`.
9. `staff_password_policy.index`: which permission does its method enforce?
10. Should `carts:send-reminders` come back? It needs a sent-guard, guest tokens, and removal of the public API trigger.
11. Is `cached_graph_data` still rendered by the dashboard? If not, drop it, since it loads every product with its stocks once a day.
