# 00 — Platform & cross-cutting map

Scope: routing, mobile API, database schema sources, config, `Helpers.php`, the `app/` layout, dead code, tests, tooling, git and deploy.
Snapshot: branch `docs/codebase-map-2026-09-29` (= `origin/dev` @ `5ed0137e`), 2026-09-29. Read `CLAUDE.md` first. This file adds to it and does not repeat it.

Conventions: `file:line` points into this snapshot. **[unverified]** means the claim comes from reading code or commit messages only and was not checked against production or a running app. No artisan, composer or DB command was run to write this.

> **Security findings.** While mapping the routes I found several unauthenticated endpoints that are critical: an admin-login backdoor, a zip-extract-to-webroot "updater", and data-mutating demo endpoints. They are listed in §3.3 and should be triaged before anything else in this document.

---

## 1. Stack & versions

| Layer | Version / fact | Source |
|---|---|---|
| PHP | `^8.2` required. Prod reloads `php8.2-fpm` [unverified]. The dev machine runs 8.4 | `composer.json`, `deploy-refresh.sh` on branch `fix/shared-cache-2026-08-15` |
| Framework | Laravel **10.50.2** | `composer.lock` |
| Auth | Sessions (web), **Sanctum 3.3.3** (API). `sanctum.expiration = null`, so tokens never expire | `config/sanctum.php:47` |
| ACL | spatie/laravel-permission 5.11.1 (`permission:` route middleware + controller `__construct` middleware) | `app/Http/Kernel.php` |
| Audit | owen-it/laravel-auditing 13.7.5 (`audits` table, migration `2026_09_17_155116`) | |
| Other libs | maatwebsite/excel 3.1, mpdf 8.3, intervention/image 2.7, predis 2.4, laravel/socialite, spatie/laravel-sitemap, twilio/sdk, stichoza/google-translate | `composer.lock` |
| Base product | Active eCommerce CMS **9.9.2** (`business_settings.current_version`, last vendor SQL `sqlupdates/v992.sql`) | `UpdateController.php:102` |
| DB | MySQL 8 (migration comments rely on online index builds). Legacy tables are **utf8mb3**, new tables utf8mb4 | `2026_09_27_000003_*`, CLAUDE.md |
| Frontend | Blade (800 `*.blade.php`), laravel-mix **2**, Bootstrap 4, jQuery, Vue 2 | `package.json` |
| Push | FCM HTTP v1 over raw HTTP with `storage/app/kapitano.json`. No kreait package is installed, even though `routes/api.php:13-15` imports kreait classes | `app/Traits/FirebaseNotificationTrait.php:37,77` |
| OTP / WhatsApp | `services.otp_provider` (default `buzzbip`), Ishaar | `config/services.php:71-91` |
| Payments in use | Cash on Delivery + Paymera (per CLAUDE.md). About 25 other gateways are bundled (§7) | |
| Remote | GitLab `gitlab.com/alihytham97/kapitano_web` | `git remote -v` |

## 2. Directory map

**`app/`** (PHP file counts)

| Path | # | Notes |
|---|---|---|
| `Http/Controllers/` (top level) | 101 | Most **admin** controllers live here (OrderController, ProductController, …), not in `Admin/` |
| `Http/Controllers/Admin/` (+`Report/`) | 7 + reports | Kapitano-era only: AuditLog, DeletedOrder, PartnerStore, ProductCategoryBulk, StaffPassword, WhatsAppCampaign/Template, `Report/*` |
| `Http/Controllers/Api/V2/` | 80 | Mobile customer API; duplicates the web logic |
| `Http/Controllers/Api/V2/Seller/` | 17 | Seller app API |
| `Http/Controllers/Seller/` | 22 | Seller web panel (namespace group in `routes/seller.php:41`) |
| `Http/Controllers/Payment/` | 20 | Vendor gateway controllers (§7) |
| `Http/Controllers/Auth/` | 5 | Laravel UI auth |
| `Http/Middleware/` | 23 | `EnsureSystemKey`, `IsAdmin/IsSeller/IsCustomer/IsUser`, `AppLanguage`, `HttpsProtocol`, … |
| `Http/Resources/V2/` | ~95 | API resources/collections |
| `Http/Requests/` | 16 | |
| `Http/Helpers.php` | 5,910 lines, 247 `function` defs | Global helpers, autoloaded via composer `files` (§6) |
| `Models/` | 193 | 19 `*Translation` companions, 21 `Preorder*` |
| `Services/` | 46 | Newer, class-based. `PartnerCatalog/` (12), `OTP/` (9 vendor SMS drivers), `Revoke/` (social token revoke), `FakeDiscountService`, `ReportFilterService`, `PaymeraService`, `OtpService`/`OtpSenderService`, `BuzzBipService`, `IshaarService`, `ProductService`, `OrderService` |
| `Utility/` | 29 | Mostly static "do one flow" classes: `PaymeraUtility`, `CouponUtility`, `CartUtility`, `CustomerSegmentUtility`, `WhatsAppCampaignUtility`, `SellerPushUtility`, `CompensationCouponUtility`, gateway helpers (`Nagad`, `Ngenius`, `Payhere`) |
| `Traits/` | 2 | `FirebaseNotificationTrait` (all push sending), `PreventDemoModeChanges` |
| `Console/Commands/` | 11 | See scheduler below |
| `Exports/` 11, `Mail/` 9, `Notifications/` 10, `Providers/` 6 (+ `RouteServiceProvider.txt`, the vendor template the updater copies over) | | |

The split between Services, Utility and Traits has no rule behind it. In practice, "Service" is newer, injectable, or holds external API clients. "Utility" is static and grew out of the CMS. Search both before adding logic (CLAUDE.md).

Scheduler (`app/Console/Kernel.php`): `notifications:send-scheduled` (class `Sent.php`) every minute; `search:aggregate-events` every 10 min; `search:recompute-scores` hourly; `paymera:expire-app-checkouts` every 5 min; `seller-whatsapp:process` and `whatsapp-campaigns:process` every minute; `partners:sync` daily 04:00. `carts:send-reminders` is commented out. A server cron running `schedule:run` is assumed **[unverified]**. Queue default is `sync` and no worker is assumed.

**Other roots**

| Path | Contents |
|---|---|
| `routes/` | 23 files (§3) |
| `resources/views/` | `backend/` (admin), `seller/`, `frontend/` (themes `classic`, `megamart`, `metro`, `minima`, `reclassic`, plus per-gateway folders), `preorder/`, `installation/`, `update/`, `uploader/`, `emails/`. Also `resources/lang`, `resources/pwa` (seller PWA), `resources/js`, `resources/sass` |
| `database/` | `migrations/` (149), `seeds/` (4, pricing seeders), `scripts/` (read-only SQL + runbook for the SYP price fix), `factories/`, `database.sqlite` |
| `sqlupdates/` | Vendor `v15.sql` … `v992.sql` (not just v15–v23 as CLAUDE.md says), plus 3 Kapitano hand-run files (§5) |
| `config/` | §5.4. Also contains a committed `config/error_log` (old cPanel PHP log) |
| `public/` | Only **13 tracked files**: `index.php`, `.htaccess`, `uploads/.htaccess`, 3 fonts, 2 bulk-upload xlsx, favicon, robots, a video, `storage`. `public/assets/` (CSS, JS, images, fonts) is tracked since 2026-10-09, taken from production (see 05 §6.1) |
| `tests/` | 72 Feature + 7 Unit test files (§8) |
| `scripts/` | `check_permissions_exist.php`, a pre-deploy check that every `permission:` name used in code exists in the DB (`php artisan tinker scripts/check_permissions_exist.php`) |
| `docs/` | `permissions-reference.md`, and this folder |
| root misc | `SEARCH_API.md` (Arabic spec of `/api/v2/search/*` for Flutter), `llms.txt`, `kapitano.json` (**Firebase service account, tracked in git**, never print it), empty file `git`, `web.config` (IIS), `server.php` |

## 3. Route map

### 3.1 Loading (`app/Providers/RouteServiceProvider.php`)

The `$namespace` is `null`. Every file is registered in `map()` in this order: api, api_seller, admin, seller, affiliate, refund_request, club_points, otp, offline_payment, african_pg, paytm, pos, seller_package, delivery_boy, auction, wholesale, preorder, cybersource, **web last**. `install.php` and `update.php` are commented out and **not loaded**. `channels.php` and `console.php` are framework files.
Rate limiter `api` allows 600 req/min per user id or IP.

Middleware groups (`app/Http/Kernel.php`):
- **global**: `ValidatePostSize, TrimStrings, ConvertEmptyStringsToNull, TrustProxies, StartSession, ShareErrorsFromSession`. `StartSession` is global, so **API calls also start sessions**.
- **web**: `AddQueuedCookies, VerifyCsrfToken, SubstituteBindings, Language, HttpsProtocol, CheckForMaintenanceMode`. **`EncryptCookies` is commented out**, so cookies are not encrypted.
- **api**: `EnsureFrontendRequestsAreStateful, throttle:api, SubstituteBindings, CheckForMaintenanceMode, EnsureSystemKey`.
- aliases: `admin, seller, customer, user, unbanned, checkout, isPreorder, app_language, app_user_unbanned, prevent_db_action, prevent-back-history, handle-demo-login, role, permission, role_or_permission`.

CSRF exemptions (`VerifyCsrfToken::$except`): `sslcommerz*, config_content, paytm*, payhere*, stripe*, api/v2/stripe*, iyzico*, payfast*, bkash*, aamarpay*, mock_payments, apple-callback, lnmo*, rozer*, phonepe*, /import-data, cyber-source/*, payment/trigger, payment/paymera/trigger/*, paymera/trigger, api/paymera/trigger`.

### 3.2 Per-file table

Count = `Route::get/post/put/patch/delete/any/match/resource/apiResource` statements, excluding commented lines. A `resource` expands to up to 7 routes. "Missing ctrl" means a controller that the file imports **does not exist in the repo**. Those routes register fine, but hitting them returns 500 "Target class does not exist". Either the addon is inactive or its code lives only on the server **[unverified]**.

| File | Loaded | Prefix / base middleware | Count | Notes |
|---|---|---|---|---|
| `web.php` (806 l.) | yes (last) | web | 270 + `Auth::routes(['verify'=>true])` | Storefront, customer dashboard, checkout (**guest**, no auth, `web.php:546`), payment callbacks, test/demo routes (§3.3) |
| `admin.php` (1028 l.) | yes | web. Main group `prefix admin`, `auth, admin, prevent-back-history` at `:167-1026`, plus per-route `permission:` | 488 | Also defines the vendor `/update` routes (`:152-158`) outside the group. Missing: `Cybersource\CybersourceSettingController` |
| `seller.php` | yes | web. `prefix seller`, `seller, verified, user, prevent-back-history`, `as seller.` | 92 | `:10-18` public PWA assets (by design) |
| `api.php` (710 l.) | yes | `prefix api`, **api** group | 285 | Namespace `App\Http\Controllers\Api\V2`. §4 |
| `api_seller.php` | yes | `prefix api/v2/seller`, api + `app_language` + `auth:sanctum` | 87 | No seller-role middleware. A few methods check `user_type` themselves |
| `preorder.php` | yes | web, `isPreorder` (404 unless addon `preorder` is active) | 88 | All 11 `Preorder\*` controllers missing |
| `pos.php` | yes | web; admin / seller groups | 29 | `PosController` missing |
| `affiliate.php` | yes | web | 26 | `AffiliateController` missing. `POST /affiliate/store` unauth (`:54`) |
| `auction.php` | yes | web | 26 | `AuctionProductController`, `AuctionProductBidController` missing |
| `african_pg.php` | yes | web | 25 | Mpesa/Payfast/Flutterwave; all 4 controllers missing |
| `delivery_boy.php` | yes | web | 22 | `DeliveryBoyController` missing |
| `refund_request.php` | yes | web | 19 | `RefundRequestController` missing |
| `offline_payment.php` | yes | web | 17 | `ManualPaymentMethodController`, `SellerPackage*` missing |
| `otp.php` | yes | web | 16 | `OTPController`, `OTPVerificationController`, `SmsController`, `SmsTemplateController` missing. Real OTP login is in `HomeController`/`AuthController` |
| `wholesale.php` | yes | web | 14 | `WholesaleProductController` (web) missing |
| `club_points.php` | yes | web | 11 | `ClubPointController` missing |
| `paytm.php` | yes | web | 10 | Paytm/Toyyibpay/Khalti/Phonepe; all missing |
| `seller_package.php` | yes | web | 7 | `SellerPackageController` missing |
| `cybersource.php` | yes | web, admin + `permission:payment_methods_configurations` | 1 | Controller missing |
| `install.php` | **no** | — | 10 | Vendor installer |
| `update.php` | **no** | — | 1 | But `admin.php:152-158` re-declares the same controller **and it is loaded** |
| `channels.php`, `console.php` | framework | — | 0 | |

**Route names are unique** since 2026-10-03, so `php artisan route:cache` works. The CMS had 78 names registered twice (a `Route::resource()` next to an explicit `…/edit/{id}` route with the same name, `api/v2` routes named like admin ones, `Auth::routes()` names reused by `HomeController`, …); `route:cache` refuses to serialize those. Laravel's name lookup is last-wins, so the fix renamed only the route that `route('name')` did *not* resolve to (`brands.edit.resource`, `api.brands.index`, `admin.reviews.index`, `register.form`, `password.update.token`, `verification.resend.post`, …) and changed no URI, action or middleware; `Auth::routes()` is expanded in `web.php` for that reason. Oddities kept as they were: `route('reviews.index')` / `subscribers.index` give the customer-side `/reviews` and `/subscribers` (not the admin pages), `email-templates.index` needs the `{id}` (`profile.edit` was the Iyzico licence backdoor, removed 2026-10-12, §3.3). Pinned by `tests/Feature/RouteNamesAreUniqueTest.php`, which names the two routes of any new duplicate. `config:cache` is safe too since the `env()` calls moved into the config files (§5.4).

### 3.3 Unauthenticated state-changing routes

Method: I parsed every route file and tracked enclosing `group` middleware plus chained `->middleware()`. A route was flagged when nothing in its chain contains `auth`, `auth:sanctum`, `admin`, `seller`, `customer`, `user` or `isPreorder`. The scan flagged 239 POST/PUT/DELETE/ANY/resource routes. Controller-level `__construct` middleware is **not** seen by this scan (for example, `admin.php:143` `/admin/app-settings/update` is guarded by `PageController`'s `permission:edit_website_page`). Unauthenticated GETs that mutate data were added by hand.

**A. Critical: triage first** (code-reading only, not exploited)

| file:line | Route | Why |
|---|---|---|
| ~~`admin.php:1028`~~ | ~~`GET /system/sitemap-item-add/{item}`~~ | **Removed 2026-10-12** with every other vendor licence check (see below) |
| `admin.php:153` | `POST /update` → `UpdateController@step0` | Extracts an uploaded zip (`update_zip` = any `uploads.id`) **into `base_path()`**. A CSRF token is free from `GET /refresh-csrf` (`web.php:221`), and any logged-in user can upload a `.zip` via `/aiz-uploader/upload` |
| `admin.php:154-156` | `GET /update/step1..3` | `step2` runs `sqlupdates/*.sql` via `DB::unprepared`, and if `current_version` is not in its list it **copies `RouteServiceProvider.txt` over `RouteServiceProvider.php`** |
| `admin.php:157` | `POST /purchase_code` | Writes `business_settings.purchase_code` |
| ~~`web.php:202-210`~~ | ~~`GET /convert_tax`, `/set-category`, `/insert_product_variant_forcefully`, `/migrate_attribute_values`, `/convert_assets`, `/convert_category`, `/update_seller_id_in_orders/{min}/{max}`~~ (DemoController), `/demo/cron_1/2` | **Routes removed 2026-10-21** (`RemovedOpsRoutesTest`); `/demo/cron_1/2` still listed here was not found routed. Were: Unguarded bulk writes. `/set-category` re-inserts every product into `product_categories` (no unique key, so duplicates). `/convert_tax` duplicates `product_taxes`. `cron_1` drops all tables but only when `DEMO_MODE=On` |
| `web.php:382` | `POST /import-data` (CSRF-exempt) → `BusinessSettingsController@import_data:671` | `DB::unprepared(public/demo.sql)`. Guarded only by the vendor activation check |
| ~~`api.php:115`~~ | ~~`POST api/v2/command-php`~~ | **Removed 2026-10-21.** Ran `composer require kreait/laravel-firebase` on the server. Guarded only by System-Key |
| ~~`api.php:90`~~ | ~~`GET api/v2/send-notiii`~~ | **Removed 2026-10-21.** Ran `Artisan::call('optimize:clear')` (whole cache + route/config caches) for anyone with the app's System-Key |
| ~~`api.php:71`~~ | ~~`GET api/v2/send-cart-reminders`~~ | **Removed 2026-10-21.** Ran `carts:send-reminders` (sends messages) |
| ~~`api.php:23`~~ | ~~`POST api/v2/test-notification`~~ | **Removed 2026-10-21.** Sent FCM to any `user_id` / device token |
| `api.php:500` | `POST api/v2/product/add/ai` → `Api\V2\ProductController@store:77` | **Exempt from System-Key.** Only gate is a hardcoded token literal in code (`683d***`) |
| `api.php:471,473` | `POST api/v2/paymera/check-payment`, `/cancel-payment` | Exempt from System-Key (`api/v2/paymera/*`). Anyone with a `payment_id` can sync or cancel |
| `web.php:254` | `GET /payment/cancel/{paymentId}` | Cancels any Paymera payment by id. `:252` `/payment/status/{id}` and `:238` `/pay` are also open |
| `web.php:130` | `GET /test/whatsapp-otp?phone=&code=` | Sends a WhatsApp OTP to any number (cost / abuse). A real default number is hardcoded |
| `web.php:99` | `GET /test-convert-usd` (and `/test-mail`, removed 2026-10-21) | Debug leftovers; `/test-mail` sent to a hardcoded personal address |
| `web.php:227-230` | `POST /aiz-uploader`, `/upload`, `/get_file_by_ids` | No route auth. `upload` dereferences `Auth::user()` (`AizUploadController.php:268`), so any logged-in customer can upload (zip allowed) |

**Vendor licence checks: all removed 2026-10-12.** The CMS called home to `activation.activeitzone.com` with the server name and acted on the answer `bad`. On admin pages (`CoreComponentRepository::instantiateShopRepository()` / `initializeCache()` in the product, category, attribute, commission and settings screens) it redirected away, so those pages waited on a remote call every time and never opened on localhost. `initializeCache()` also switched off every `addons` row without a purchase code (the table is empty, so this changed nothing). Public routes used the same answer as kill switches: `GET /translation-check/{x}`, `/checkout-payment-detail`, `/customer-products/admin` (via `Handler::render` → `NgeniusUtility::initPayment`) and `/system/sitemap-item-add/{x}` logged the visitor in as the first admin; `GET /compare/details/{addon}` dropped that addon's tables (`translation_tables()`, with `bad` hard-coded, so no remote answer was needed); `GET /vogue-pay/callback` dumped the database to `sqlbackups/` and dropped every table. All of it is deleted (routes, methods, `translation_tables` / `seller_homepage_urls` / `TimeDateFormatter`, `CategoryUtility::create_initial_category`, `NagadUtility::create_balance_reference`, `PayhereUtility::create_wallet_reference`), and `tests/Feature/VendorLicenseChecksRemovedTest.php` fails if any of it comes back. **The last calls went on 2026-11-06**, so nothing calls the vendor any more: `hex2rgba()` (Helpers.php, used by the storefront and sign-in layouts on every page) went through the package `aiz-packages/color-code-converter`, which on one call in ten sent our server name and our `addons` list to the activation server (and, without `$_SERVER['SERVER_NAME']`, failed the request: a random 500 in tests) - it now converts by itself, the same steps, compared on 6,228 inputs with no difference (`tests/Unit/Hex2RgbaTest.php`); the addon installer no longer asks for a purchase code (it asked the activation server; the upload is admin only); the Addons page no longer loads the vendor's addon shop from their site (it also put their HTML into the page unescaped). The packages `aiz-packages/color-code-converter` and `mehedi-iitdu/core-component-repository` were removed from `composer.json`; the tripwire test fails if either returns. Plain links to the vendor's site remain only in the installer / updater views and the demo navigation, which make no request.

**B. OTP / SMS senders with no throttle** (the web group has no `throttle`; api has only the global 600/min): `web.php:126` `/otp/whatsapp/send`, `web.php:171,176` `/auth/whatsapp/send-otp|verify-otp`, `web.php:112,119` `/checkout/check-phone|verify-phone`, `web.php:295,297,304,306` registration codes, `web.php:322,327,331` seller phone login/register, `web.php:371-378` customer phone login/resend/verify/register, `api.php:21,189` `v2/send-otp|verify-otp`, `api.php:196-199,212-214` login/signup/password reset, `otp.php:22-33`.

**C. Public by design** (guest cart/checkout, browsing POSTs, gateway callbacks). Keep them public, but they must stay idempotent:
- Guest cart and checkout: `web.php:149,468-472,550-558`; `api.php:229,263-300` (cart, guest-cart-info, shipping, address-in-cart).
- Browsing POSTs that only read: `web.php:388-408,432,435,457-462,519,626-627`; `web.php:189` (session `currency_type`); `web.php:524` `resource('subscribers')` (store is public; **destroy/update also unauthenticated [unverified]**); `web.php:805` contact; `web.php:793` blog slug; `api.php:172,174,441,526,561,633`.
- Paymera triggers (server-to-server): `web.php:186,243,249`, `api.php:221`. **`api.php:221` (`/api/paymera/trigger`) is not exempt from System-Key**, so Paymera calls to it get `{"result":false}`. The URL actually sent to Paymera is `api.paymera.trigger` = `web.php:186` (`Api/V2/CheckoutController.php:444,630`).
- Seller order-action links by token: `web.php:774`. Shop registration: `web.php:284` (`resource('shops')`). Apple sign-in: `web.php:274`.
- Unused-gateway callbacks (§7): `web.php:482-491,508-509,673,687-724,740-755`; `api.php:574-601,610,647-692`; all of `african_pg.php:15-53` and `paytm.php:13-41`; `offline_payment.php:45-58`; `affiliate.php:54`; `auction.php:67`; `install.php:23-26` (not loaded).

## 4. Mobile API conventions

- **Base**: `/api/v2/...` (customer), `/api/v2/seller/...` (seller app). There is no other version. `Api/V2` is the only API namespace.
- **System-Key**: `EnsureSystemKey` requires header `System-Key == config('app.system_key')` (`SYSTEM_KEY` env). On failure it returns **HTTP 200** `{"result":false,"message":"Request not found!"}`. The key ships inside the mobile apps, so it is an app identifier, not a secret, and gives no real protection.
  Exemptions: hardcoded paths `api/v2/order/cancel2/*`, `api/v2/paymera/*`, `api/v2/product/add/ai` (`EnsureSystemKey.php`), plus `Route::withoutMiddleware([EnsureSystemKey::class])` at `api.php:641-699` (wholesale pages, reCAPTCHA view, all gateway success/cancel/callback routes).
- **Language**: header `App-Language` (`AppLanguage` middleware, falls back to `env('DEFAULT_LANGUAGE')` or `en`). Applied to the `v2` and `v2/auth` groups. It is also part of cache keys (`SEARCH_API.md`).
- **Auth**: `Authorization: Bearer <sanctum token>`. Tokens come from `AuthController::loginSuccess()` (`createToken('API Token')`, `AuthController.php:812`) after password, social or OTP login (`v2/send-otp`, `v2/verify-otp`). Guests use `temp_user_id` for carts (`v2/guest-user-account-create`). `auth:sanctum` is attached **per route**, not per group. Check every new route. The banned-user check is the `app_user_unbanned` group at `api.php:307`. Sanctum's guard saved `last_used_at` on every token request; `App\Models\PersonalAccessToken` (registered in `AuthServiceProvider`) writes it at most every 10 minutes.
- **Rate limit**: `throttle:api` per bearer token / guest cart (600) with a ceiling per address (6000), `throttle:device-token` 30 per phone; `app.api_limit_per_user=false` puts back 600 per address (CLAUDE.md).
- **Cached answers**: the catalogue GETs carry `api_cache:<profile>` (CLAUDE.md "App catalogue answers are cached as text"): a hit is the stored body, no database query; the System-Key check and the rate limit still run first.
- **Bundled calls** (new app versions, 2026-10-22, `docs/api/app-data-sync.md`): `POST v2/app/data` (launch + home groups, each answered `null` when the app's stamp names the same content) and `POST v2/checkout/overview` (the checkout page's seven calls). Both run old endpoints inside the request with `App\Services\AppData\LegacyEndpoint` - a sub-request with the app's headers through the route's own middleware (System-Key, rate limit - each part counts like the old call -, auth, `api_cache`), then the outer request, default guard and current route are put back - and answer their bodies as they are, so old and new app versions can never get different content. An endpoint that is a group or a part must answer the same when called that way: it gets the app's headers and the params in `AppDataSync::GROUPS` / the app's body, never the outer request's input.
- **App version**: `GET api/v2/app/version` → `BusinessSettingController@version:46`. It returns `{success, android:{latest_version, minimum_version, force_update, store_url}, ios:{…}}` from `business_settings`, edited at `admin.php:140-144` (`/admin/app-settings`). Until 2026-10-12 it (and `event-logo`) ran `Artisan::call('cache:clear')` on every call, so every app launch emptied the whole cache; both now read their settings straight from `business_settings` (`freshSettings()`, so a hand edit in the database still shows on the next launch) and leave the cache alone. That wipe had hidden every writer that never refreshed its cache; those now do (CLAUDE.md "A product change never empties the whole cache"). Production runs one server with the file cache (confirmed 2026-10-12).
- **Response shapes** (inconsistent, a CMS legacy): action endpoints use `{result: bool, message}` (`Api/V2/Controller::success()/failed()`). Resource collections add `{success: true, status: 200}` through `with()` (e.g. `ProductMiniCollection.php:79`). Some newer endpoints use `{success, data}`. Unknown routes hit `Route::fallback` → HTTP 200 `{data:[], success:false, status:404, message:'Invalid Route'}` (`api.php:703`). Exceptions under `api/*` are JSON via `Handler::renderApiException` (422/401/404/405/500 with `{result:false,message}`, and debug details only when `APP_DEBUG`).
- **Duplication**: `Api/V2/*Controller` mirrors web controllers (cart, checkout, coupons, Paymera). Fix both.

## 5. Database

### 5.1 Schema sources: no single source of truth

1. **Production DB**. The core CMS tables (`products`, `orders`, `users`, …) are **not defined anywhere on `dev`**. The vendor installer `shop.sql` is gitignored (`*.sql`).
2. **`sqlupdates/v15.sql … v992.sql`**: vendor incremental SQL, executed by `UpdateController@step2` (84 `CREATE TABLE`s in total). Only the versions from 9.3 onward are wired in (`UpdateController.php:102`).
3. **Kapitano hand-run SQL** (tracked despite `*.sql` in `.gitignore`, so it had to be `git add -f`ed): `feature_guest_cart_infos.sql` (`guest_cart_infos`), `kapitano_orders_coupon_filter_2026-09-26.sql` (translations only), `kapitano_compensation_coupon_2026-09-29.sql`. `kapitano_coupon_usage_report_2026-09-05.sql` is referenced in comments but **absent**; the `2026_09_05_*` migrations cover it.
4. **`database/migrations/`** (149 files): 4 framework/vendor ones (`users`, `password_resets`, `personal_access_tokens`, `product_queries`), then **Kapitano migrations from 2026-08-24 to 2026-09-29**. About half of them only seed `translations` or `permissions`. They are defensive (`hasTable`/`hasColumn`/index-exists checks).
   `2000_01_01_000000_record_existing_tables_in_migrations.php` marks vendor migrations as run when their tables already exist. Run it alone first on a DB that throws "Base table already exists".
5. **Unmerged branch `origin/migrations`** (`2369d4cf`, 2026-09-10): 119 `create_*` migrations reverse-engineered from production. It is the only readable copy of the core schema, and the column lists below come from it **[unverified vs prod; older than later ALTERs]**. It also shows prod tables named **`poc_vuln_marker`** (one `note` column, which looks like a past intrusion proof-of-concept) and `temp_auth` (phone + OTP).

### 5.2 Key tables

Legend for "defined in": **P** = prod-only / `origin/migrations` reconstruction, **V** = vendor sqlupdates, **M** = migration on dev (date).

| Table | Purpose | Key columns | Defined in |
|---|---|---|---|
| `users` | All actors (`user_type` customer/seller/admin/staff/delivery_boy) | `user_type, name, phone, email, password, banned, balance, device_token, referral_code, city_id, password_changed_at, password_history` | P + M 09-19 |
| `products` | Catalogue (prices **RAW**) | `user_id, category_id, brand_id, unit_price, real_price, purchase_price, exchange_type`(default `kapitano`)`, discount, discount_type, current_stock, published, approved, todays_deal, featured, slug, num_of_sale, search_score, *_position` | P + M 08-24, 09-23 |
| `product_stocks` | Variants (price RAW) | `product_id, variant, sku, price, real_price, qty, image, images` | P + M 09-10 |
| `product_translations` | i18n | `product_id, lang, name, unit, description` | P |
| `product_categories` | Pivot (category pages) | `product_id, category_id`. No PK; indexed `(category_id, product_id)` and `(product_id)` since `2026_10_12_000001` | P |
| `categories` | Tree | `parent_id, level, order_level, commision_rate, slug, featured, is_hidden, search_score` | P + M 09-26 |
| `brands` | | `name, slug, order_level, search_score` | P + M 09-09 |
| `shops` / `sellers` | Seller storefront / legacy seller | `user_id, name, slug, verification_status, commission_percentage, order_level, show_hidden_product_fields` | P + M 09-09/10 |
| `carts` | Cart lines (**BASE**) | `user_id, temp_user_id, owner_id, product_id, variation, price, tax, shipping_cost, coupon_code, coupon_applied, quantity, status, address_id` | P |
| `guest_cart_infos` | Guest phone per `temp_user_id` | `temp_user_id`(unique)`, phone` | Kapitano SQL |
| `combined_orders` | One checkout | `user_id, grand_total, shipping_cost, payment_id`(Paymera)`, coupon_id, coupon_code, coupon_discount` | P + M 09-06 |
| `orders` | One per seller per checkout (**BASE**) | `combined_order_id, user_id, seller_id, grand_total, coupon_discount, coupon_id, coupon_code, payment_type, payment_status, delivery_status, date`(int, used for rate history)`, seller_confirmed_at, seller_unavailable*, payment_settled_by/at, cancelled_*` | P + M 09-05/14/21 |
| `order_details` | Lines (**BASE**) | `order_id, seller_id, product_id, variation, price, tax, shipping_cost, quantity, delivery_status, seller_unavailable*` | P + M 09-21 |
| `deleted_orders` | Archive of hard-deleted orders | snapshot + `acting_as_*` | M 09-05/07 |
| `coupons` / `coupon_usages` / `coupon_allowed_users` | Coupons | `code, type, details`(JSON, holds `compensation`)`, discount, discount_type, start_date, end_date, status, visible, platform, max_users`; usages `user_id, coupon_id, order_id` | P + M 09-05/09/17 |
| `discounts` / `discount_products` | Fake discounts (§CLAUDE.md) | percent, linked products | V/P [unverified] |
| `currencies` | Rates | `code, exchange_rate, real_rate, symbol, status` | P |
| `business_settings` | Key/value settings (`get_setting`) | `type, value, lang` | P |
| `translations` | UI strings (`translate()`) | `lang, lang_key, lang_value` | P (many M seeds) |
| `uploads` | File registry (assets by id) | `file_name, file_original_name, user_id, extension, type, external_link, deleted_at` | P |
| `addresses` / `cities` / `states` | Delivery | `user_id, city_id, state_id, phone, set_default, notes`; cities `cost, carttotal` | P + M 09-22 |
| `notifications` | Laravel DB notifications | `notifiable_type/id, type, read_at, data` | P + M 09-27 index |
| `firebase_notifications`, `custom_notification_batches/_failures/_recipients` | Push history / campaigns | `receiver_id, item_type, is_read`; batches `audience` | P + M 09-15/16/26 |
| `seller_push_tokens`, `seller_order_notifications` | Seller PWA push / WhatsApp alerts | | M 09-04, 09-18 |
| `paymera_app_checkouts` | App Paymera sessions | `payment_id, combined_order_id, status` | M 09-10 |
| `order_follow_ups` (+`_items`, `_logs`), `delivery_agents` | CS follow-up / delivery assignment | employee ids, timestamps | M 09-12/16/19 |
| `cart_retargets`, `cart_retarget_holds` | Abandoned-cart retargeting | `cart_key, phone, items_snapshot, converted_order_id` | M 09-09/12/24 |
| `whatsapp_campaigns`, `whatsapp_campaign_recipients`, `whatsapp_templates`, `marketing_contacts` | Marketing | | M 09-24 |
| `partner_sources/_products/_categories/_brands/_sync_runs` | Partner catalog import (utf8mb4) | `partner_products.product_id, product_stock_id, stock_links, decision, source_state` | M 09-26, 10-03 |
| `search_events`, `search_queries` | Search analytics | | M 08-24 |
| `permissions`, `roles`, `model_has_*`, `role_has_permissions` | Spatie ACL | `name, section` | P + many M seeds |
| `audits` | owen-it audit log | | M 09-17 |
| `personal_access_tokens` | Sanctum | | M (vendor) |

### 5.3 Indexes and performance

Added on dev: `product_translations.product_id` and `product_stocks.product_id` (`2026_08_24_000005`; search went from ~22s of full scans to indexed), FULLTEXT `products(name,tags)` (`…000006`), `notifications(notifiable_type, notifiable_id, read_at, type, created_at)` (`2026_09_27_000001`), `order_details.order_id` and `orders.combined_order_id` (`2026_09_27_000003`), `coupon_usages_user_coupon_index` (`2026_09_09_000001`). Every one of them checks for an existing index before creating, because prod may differ.

`product_categories(category_id, product_id)` and `(product_id)` (`2026_10_12_000001`; the homepage category sections took 241 s on a cold cache through a correlated `whereHas('categories')`, 0.25 s after the `whereIn` rewrite + index).

**Hot lookup indexes (`2026_10_17_000001`, or `sqlupdates/kapitano_hot_indexes_2026-10-17.sql`)**, added because the site crawled with 100+ users at once: `carts(user_id)`, `carts(temp_user_id)`, `products(slug(191))` (MEDIUMTEXT, so a prefix; product page + app details), `products(category_id)`, `(brand_id)`, `(user_id)`, `product_taxes(product_id)` (`Product::$with` loads taxes on every product query), `orders(user_id)`, `(seller_id)`, `order_details(product_id)`, `(seller_id)`, `combined_orders(user_id)`, `users(phone)` (OTP / login), `wishlists(user_id, product_id)`, `reviews(product_id)`, `flash_deal_products(flash_deal_id)`, `last_viewed_products(user_id)`, `category_translations(category_id, lang)`, `brand_translations(brand_id)`, `firebase_notifications(receiver_id)`, `shops(user_id)`, `searches(query(191))`, `addresses(user_id)`. Same guard as the others (skipped when the column already leads an index); one ALTER per table (products has a FULLTEXT index: quiet hour). The list is `INDEXES` in the migration; `HotIndexesMigrationTest` checks every column against the production schema.

Still missing after that, per the reconstruction **[unverified vs prod]** (measured 2026-10-12 on the local copy: each costs only a few ms at today's table sizes): `carts.user_id` / `carts.temp_user_id`, `orders.user_id` / `seller_id` / `delivery_status`, `order_details.product_id` / `seller_id`, `products.category_id` / `user_id` / `slug` / `published`, `coupon_usages.order_id`, `addresses.user_id`, `uploads.user_id`, `firebase_notifications.receiver_id`, `business_settings.type`, `translations(lang, lang_key)`, `cities.state_id`. The reconstruction shows **0 indexes** on `orders`, `order_details`, `carts`, `coupons`, `addresses`, `translations`, `uploads`, `wallets`, `shops`.

### 5.4 Config highlights

| Key | Value / default | Note |
|---|---|---|
| `app.system_key` | `SYSTEM_KEY` | API header (§4) |
| `app.asset_path_prefix` | `ASSET_PATH_PREFIX` | |
| `app.timezone` | `APP_TIMEZONE` default UTC | |
| `app.locale` | `DEFAULT_LANGUAGE` | |
| `paymera.*` | `PAYMERA_BASE_URL` (**default `https://egate-t.paymera.cc`, the test gateway**), `_USERNAME`, `_PASSWORD`, `_TERMINAL_ID` | Prod must set the base URL |
| `services.otp_provider` | `OTP_PROVIDER` default `buzzbip` | `buzzbip.*` (template ids incl. seller order/reminder), `ishaar.*` |
| `services.google/facebook/twitter` | redirect `APP_URL/social-login/{p}/callback` | |
| `cache.default` / `session.driver` | default `file` / `file` (session lifetime default 43200 min, `encrypt=false`, `secure` from env, `same_site=null`). Production: **one server** (2026-10-12); moving both to **redis** (`CACHE_DRIVER=redis`, `SESSION_DRIVER=redis`: sessions in redis db `REDIS_DB` 0, cache in `REDIS_CACHE_DB` 1, so `cache:clear` never logs anyone out; client `predis`) is step 1 of the 2026-10-17 load fix | `cache:prune-expired` only walks the file-cache folder, so after the switch it just clears what is left there. **Sessions start only in the `web` group** since 2026-10-17 (it was global: every app API call, which sends no cookie, wrote a new session file). API routes that render a page in the app webview (payment gateways, `paymera/callback`, `online-pay/*`, the `withoutMiddleware(EnsureSystemKey)` pages) take the `session` middleware group; a new one must too, or `@csrf` / `redirect()->with()` fail |
| `queue.default` | `sync` | no worker |
| `filesystems.default` | `FILESYSTEM_DRIVER` default `local`. The `local` disk root is **`public_path()`** | `aws` and `backblaze` S3 disks defined |
| `firebase.php` | kreait-style config (`FIREBASE_CREDENTIALS`), but kreait is not installed | The trait reads `storage/app/kapitano.json` |
| `mail.sending_enabled` | global kill-switch (`AppServiceProvider::boot`), default off | the low-rating alert alone bypasses it |
| `mail.driver` | `MAIL_DRIVER`: `smtp`, or `onesignal` (2026-11-09, `App\Services\Email\OneSignalTransport`, ADR 0011; keys `services.onesignal.*`, sending domain verified in OneSignal) | the Admin SMTP settings page writes it too and offers OneSignal; no SMTP fallback at runtime |
| `app.exchange_rate_history_path` | see CLAUDE.md | |

**`env()` is no longer called outside `config/`** (since 2026-10-03). The CMS called it ~780 times from controllers, helpers, Blade views, migrations and seeders (`APP_NAME`, `DEMO_MODE`, `DEFAULT_LANGUAGE`, `FILESYSTEM_DRIVER`, payment keys, …), and after `php artisan config:cache` every one of them returned `null`. Each variable is now read in the config file it belongs to and the code reads that key: the 53 that Laravel/package config already read (`app.name`, `app.url`, `app.system_key`, `cache.default`, `session.driver`, `filesystems.default` + `filesystems.disks.aws/backblaze.*`, `database.connections.mysql.*`, `database.redis.default.*`, `mail.*`, `services.mailgun/stripe/google/facebook/twitter.*`, `paystack.*`, `mercadopago.*`, `nagad.*`, `nexmo.*`, `laravel-payku.*`) are reused as they are; the other 100 got new entries **without defaults** so they equal what `env()` returned: `app.default_language` / `demo_mode` / `force_https` / `price_fix_max_usd`, `database.seed_admin.*`, `services.*` grouped by provider (paypal, razorpay, sslcommerz, aamarpay, bkash, iyzico, ngenius, payhere, paymob, phonepe, tap, voguepay, myfatoorah, khalti, authorizenet, instamojo, twilio, fast2sms, sparrow, ssl_wireless, msegat, mimsms, zender, facebook app/page/pixel, google_analytics, google_maps, recaptcha, firebase web push, apple, whatsapp_chat), plus `paystack.currency`, `mercadopago.currency`, `nagad.merchant_private_key/pg_public_key`, `nexmo.sender_id`, `flutterwave.currency`. `DEFAULT_LANGUAGE` is deliberately **not** read as `app.locale`: `App::setLocale()` rewrites `app.locale` to the visitor's language on every request, so `app.default_language` is the store default. A call that had a default reads `config('x') ?? default`, because `config('k', default)` does not apply the default to a key that exists with `null`. Reusing the existing keys changes the fallback only when the variable is **absent** from `.env` (then `app.name` is `Laravel`, `cache.default` `file`, `mail.from.address` `hello@example.com`, `nagad.*` the package samples, `mercadopago.*` `''` instead of `null`); every one of those is set in `.env` except the unused Nagad/MercadoPago ones. Rules: a new variable is one line in the right config file + `config('…')`, never `env()`; after editing `.env` on a server run `config:clear` or `config:cache` again. The dynamic case `getFileBaseURL()` reads `config('filesystems.disks.<driver>.url')`. The admin paths that **write** `.env` (`BusinessSettingsController@overWriteEnvFile`, `Install/UpdateController@writeEnvironmentFile`) used `env($type)` to build the text they replace; they now read the current value from the file itself (`env_file_value()`, Helpers.php) and call `forget_cached_config()` afterwards, which drops `bootstrap/cache/config.php` when it exists so the next request sees the new value as it always did (rebuild the cache on the next deploy). Tests that set variables at runtime (`putenv`, `Env::getRepository()->set`) also set `config(['env.KEY' => …])`, since config is fixed at boot. **Never run phpunit while `bootstrap/cache/config.php` exists**: a cached config skips `.env` and `phpunit.xml`'s `<env>` overrides, so the suite would run against the real MySQL database instead of in-memory SQLite.

### 5.5 Production tuning and the capacity runbook (2026-10-21)

What the code does is in CLAUDE.md ("Measuring request cost", "App catalogue answers are cached as text", "What every request paid before its work", the rate limit). What only the server can do:

**Deploying `perf/server-load-2026-10-21`**
1. `php artisan migrate` (only `2026_10_21_000001_add_api_hot_indexes`: one ALTER per table, none with a FULLTEXT index; or `sqlupdates/kapitano_api_hot_indexes_2026-10-21.sql`).
2. `php artisan optimize` (routes changed: `api_cache` middleware, removed routes) and `php artisan event:cache` if events are cached (`EventServiceProvider` gained a listener). No `cache:clear` is needed: the settings cache reads old copies, and every cached answer is keyed by versions.
3. Optional for a few days before and after: `PERF_LOG=true`, `PERF_LOG_SAMPLE=0.05`, then `php artisan perf:report --from=... --to=...`.

**PHP**: install the phpredis extension and set `REDIS_CLIENT=phpredis` (predis is plain PHP; the settings and translations are read from redis on almost every request). OPcache on for FPM **and the CLI** (5 commands boot every minute): `opcache.memory_consumption=256`, `opcache.max_accelerated_files=20000`, `opcache.validate_timestamps=0` with an FPM reload on every deploy. Size `pm.max_children` to the RAM (≈ free RAM / ~60MB per worker). `composer install --no-dev --optimize-autoloader` (debugbar is a dev package; `APP_DEBUG=false`).

**Redis**: the cache can be evicted, sessions and the queue cannot - eviction is per instance, so give the cache its own instance with `maxmemory` + `allkeys-lru`, and keep sessions/queue on one with `noeviction` (or one instance with enough memory and monitoring). A staging server must not share the live redis, or set `REDIS_PREFIX`.

**Cloudflare**: `request()->ip()` is the visitor only if nginx restores it (`real_ip_header CF-Connecting-IP;` + `set_real_ip_from` Cloudflare's ranges); `TrustProxies` trusts nobody. Without it every rate limit and the traffic sources see Cloudflare's addresses. Check `REMOTE_ADDR` in the access log.

**Static files in browsers and the app**: Cloudflare's Browser Cache TTL (Caching → Configuration) went from 16 hours to **1 month** on 2026-10-22: every static file (images, CSS, JS, fonts - not pages or API answers) now goes out with `Cache-Control: max-age=2678400`, so phones and browsers keep it 31 days without asking. Uploads are safe (an uploaded file never changes under its name: every upload is a new random 40-character name, nothing rewrites one in place). A file under `public/assets/` changed in place reaches visitors only with a new `?v=` or name (CLAUDE.md, Commands). Optional: a Cache Rule with an Edge TTL of a month on `/uploads/all/` so Cloudflare asks the server for them less often (its default is a few hours per data center). The app side is `docs/api/mobile-performance-guide.md` §5.

**nginx**: a missing file under `/uploads`, `/assets`, `/public` should be a plain 404 from nginx (`try_files $uri =404;` in those locations) - today it boots Laravel and renders the full storefront layout; a static 500 page; gzip/brotli for JSON.

**Queue worker** (`after_response()`: pushes, WhatsApp, the app's order notifications; plus the 9 `Mail::...->queue()` mails):
1. Before the first start, look at what piled up while no worker ran (`jobs` table, or the redis queue) and clear it (`php artisan queue:clear <connection>`): those are mails and work from days or weeks ago.
2. `QUEUE_CONNECTION=redis`, `QUEUE_WORKER=true`, `php artisan config:cache`.
3. supervisor: `command=php /path/to/artisan queue:work redis --sleep=1 --tries=3 --max-time=3600`, `autorestart=true`, `numprocs=2`.
4. Every deploy: `php artisan queue:restart`, after `optimize` / `config:cache`. A worker is a long-running process that loaded the code and the config once: until it restarts it runs the old code against the new database and the new `.env` (a job for a class or column the old code does not know fails, a changed setting is ignored). `queue:restart` writes a key in the cache; each worker finishes its current job, exits, and supervisor starts it again on the new code. `--max-time=3600` would restart it within the hour anyway, but jobs in that hour run the old code.
`QUEUE_WORKER=false` puts everything back after the response in the web process.

**Live since 2026-10-29**: supervisor program `kapitano-web-queue` (one process, `queue:work redis --sleep=3 --tries=3 --max-time=3600 --max-jobs=1000 --queue=default`). The `kapitano-queue` program on the same server belongs to **captain-api**, another project. Both share the local redis:
- Queues and sessions are on `db=0`. Their keys do not collide: our redis prefix is empty (`queues:default`), captain's is `laravel-database-` (`laravel-database-queues:default`). Never `FLUSHDB` db 0 or `FLUSHALL`: it empties both projects' sessions and waiting jobs.
- **A cache prefix does not protect the cache.** `cache:clear` on redis is `FLUSHDB` on the cache database (`Illuminate\Cache\RedisStore::flush()`), whatever the prefix. Both caches were on `db=1`, so a `cache:clear` in either project emptied the other's (our OTP codes, rate limits, settings, cached app answers). Fix agreed: `REDIS_CACHE_DB=2` in captain-api's `.env`. Check with `config("database.redis.cache.database")` in each project.
- Check the worker: `pgrep -af "queue:work"`, `supervisorctl status` (root), `php artisan queue:monitor redis:default`, `php artisan queue:failed`. End to end: a script run with `php artisan tinker <file> < /dev/null` that calls `after_response()` to write a log line with `getmypid()` - the pid must be the worker's (a closure written in tinker's prompt cannot be serialized, so it has to be a file).
- captain-api needs PHP 8.3 (`/opt/cpanel/ea-php83/root/usr/bin/php`); the shell's `php` is 8.2 and is the one our site's artisan uses.

**MySQL**: `innodb_buffer_pool_size` ≥ the data (~1 GB today, `notifications` + `firebase_notifications` are ~580 MB of it), slow query log at 0.5 s to see what is left. The live server had **24 MB** (WHM default, found 2026-10-22): set it to 1 GB (3 GB once the server has 16 GB of RAM) in WHM → SQL Services → Edit Database Configuration.

**Deploying `feat/app-data-sync-2026-10-22`** (the bundled calls): no migration. `php artisan optimize` (new routes, new `app.app_data_budget_ms`). Then hand `docs/api/claude-app-data-sync-handoff.md` to the app work; old app versions need nothing.

### 5.6 Storefront page speed (2026-11-07)

PageSpeed could not measure the site until 2026-11-07: Cloudflare's custom rule "Fake android" challenged every Android browser that is not Googlebot, and PageSpeed (`Chrome-Lighthouse`) and Google's testing tools (`Google-InspectionTool`: Rich Results Test, URL Inspection live test) present as Android phones; both are now excepted in that rule. First real measure, homepage, mobile: performance 36, LCP 16.1 s (lab), TBT 2.3 s, 5.4 MB; real users (CrUX, 28 days) LCP 3.9 s, INP 269 ms. What changed in the layout (`frontend.layouts.app`):

- **Third-party scripts start after the page**: `window.kpAfterLoad(fn)` (first script in `<head>` after the homepage sections request) runs them 2.5 s after `load`, at the first scroll / tap / key, or at 7 s whatever happens (a request that never ends holds `load`); `kpLoadScript(src)` adds an async script. Zoho PageSense, Microsoft Clarity, the Meta Pixel (`fbevents.js`) and the Google tag (`gtag/js`) load that way; their queues (`fbq`, `clarity`, `gtag` / `dataLayer`) exist at once, so events recorded before (PageView, ViewContent, purchase) are sent when the script arrives. **PageSense's own snippet hid the whole page** (`opacity: 0` on `html`) until its script came, up to 10 s; it is loaded without that (an A/B test of PageSense may now flash the original for a moment). A new tracking script goes through `kpAfterLoad`, never as a plain `<script async src>` in `<head>`.
- `select2.min.css` (checkout only) loads with `media="print" onload="this.media='all'"`.
- The homepage banners under the first section (`home_banners_under_first_section`) use their real `src` with native `loading` (first eager) instead of lazysizes' `data-src`, which waited for the script (PageSpeed's LCP element).
- The section placeholders pulse with one `opacity` animation per placeholder (`kp-skel-pulse`) instead of a `background-position` shimmer on every grey shape (hundreds of non-composited animations).
- **`vendors.js` is split in two on the storefront** (1.46 MB, 412 KB gzipped, most of it unused on public pages; the admin, seller and other panels keep loading it whole). `scripts/split_vendors_js.php` copies its libraries byte for byte, by line ranges, into:
  - `vendors-core.js` (431 KB, 130 KB gzipped), on every page: jQuery, Popper, Bootstrap, metisMenu, bootstrap-select, intl-tel-input, noUiSlider, the countdowns (+ Knob), Notify, slick, lazysizes, jssocials, Zoom, throttle/debounce, particles, Spotlight;
  - `vendors-extras.js` (1 MB, 283 KB gzipped): Tagify, Summernote, moment, daterangepicker, Uppy, FooTable, timepicker, Chart.js. It loads with the page where the view says `@section('vendor_extras', '1')` (`user_panel`, so every account page; checkout, payment_select, the preorder order page: the receipt uploader). Anywhere else aiz-core.js fetches it the moment one of its eight widgets (`tagify`, `textEditor`, `dateRange`, `timePicker`, `fooTable`, `sectionFooTable`, `aizUppy`, `chart`) finds its element on the page (`AIZ.extras.need()`: one request, then each waiting widget starts on its own, so one that throws does not stop the others).

  A storefront view that calls one of those libraries itself (not through `AIZ.plugins`) needs the section; a new widget of `aiz-core.js` that uses one starts with the `AIZ.extras.need()` guard. The extras file starts with `;`: Tagify's `"use strict"` would otherwise be its first statement and make the whole file strict, where FooTable (a global set without `var`) throws and stops everything after it. When `vendors.js` changes, run the script and bump `?v=` of both files (the test fails until the files match it).

Left: the two vendor files still load synchronously at the end of `<body>` (dozens of inline scripts use jQuery right after them, so `defer` is its own piece of work); the slider's 1.2 MB GIF banner is content (marketing re-uploads it as WebP / video). Pinned by `tests/Feature/StorefrontSpeedTest.php`.

## 6. `app/Http/Helpers.php` index (5,910 lines)

| Lines | Group | Functions |
|---|---|---|
| 71-108 | Misc / routing | `sendSMS, areActiveRoutes, areActiveRoutesHome, default_language` |
| 110-164 | Legacy currency converters | `convert_to_usd, convert_to_usd_from_syp, convert_to_kes` |
| 165-305 | Catalogue caches and filters | `get_active_countries, filter_products, get_cached_products, hidden_category_ids, forget_category_caches, verified_sellers_id` |
| 306-850 | **Pricing core** (RAW/BASE/SYP contract) | `get_system_default_currency, get_syp_currency, round_price, product_exchange_type, currency_syp_factor, market_default_rate, safe_rate_ratio, product_syp_factor, round_system_price, convert_price, product_price_to_syp, product_commission_percentage, product_cost_price, product_profit_sql, apply_product_list_sort, format_system_currency_price, raw_to_base_multiplier, raw_to_base_sql, currency_symbol, format_price, single_price` |
| 851-1241 | **Order-date exchange rates** | `display_order_total, exchange_rate_history_path, exchange_rate_history, record_exchange_rate_change, exchange_rate_log_path, log_exchange_rate_change, exchange_rate_log, note_exchange_rate_change, exchange_rate_at, order_timestamp, order_exchange_rate, convert_order_price, single_order_price, order_rate_sql, format_syp` |
| 1242-1302 | Order totals | `order_is_cancelled, order_payable_total, combined_order_payable_total` |
| 1303-1936 | Cart and display prices | `shipping_price, discount_in_percentage, cart_product_price, cart_product_tax, cart_product_discount, carts_product_discount, carts_coupon_discount, product_default_stock, home_price, home_discounted_price, home_base_price(_by_stock_id), home_discounted_base_price(_by_stock_id)` |
| 1937-2041 | i18n | `renderStarRating, translate, remove_invalid_charcaters` |
| 2042-2443 | Shipping | `cart_shipping_base_total, first_shippable_cart_index, free_shipping_limit_for, free_shipping_progress, cart_shipping_cost, getShippingCost, carrier_base_price, seller_base_carrier_list` |
| 2444-2620 | Time / names | `timezones` (**defined twice**, 2444 and 4575, the second behind `function_exists`), `app_timezone, name_initial` |
| 2621-2958 | Assets / URLs / uploads | `uploaded_asset, product_photo_ids, product_image_id, preload_product_images, my_asset, static_asset, getBaseURL, getFileBaseURL, normalize_upload_url, is_own_host_url, isUnique` |
| 2959-3049 | Settings / user type | `get_setting, hex2rgba, isAdmin, isSeller, isCustomer, formatBytes, ceiling, get_images_path` |
| 3050-3220 | Payment-completion hooks | `checkout_done, get_user_total_ordered_products, order_re_payment_done, wallet_payment_done, seller_purchase_payment_done, customer_purchase_payment_done, product_restock, calculateCommissionAffilationClubPoint` |
| 3221-3271 | Addon / package checks | `addon_is_activated, seller_package_validity_check(_for_preorder_product)` |
| 3272-4294 | CMS view accessors (`get_*`, ~95 functions) | e.g. `get_admin, get_system_currency, get_featured_products, home_section_products_query (3529), best_selling_products_query (3585), get_user_cart (3886), get_coupons, get_affliate_*`, `seller_landing_route` |
| 4295-4574 | Misc product / user | `product_stock_image_ids, show_hidden_product_fields, offerUserWelcomeCoupon, ifUserHasWelcomeCouponAndNotUsed, get_image, get_pos_user_cart, get_single_cart, number_format_short, get_notification_type, get_activate_payment_methods, flash_message, get_wishlists, get_email_template_data, deleteProductReview` |
| 4575-4746 | Duplicate `timezones`, `formatToArray` | |
| 4747-4968 | Preorder addon | `preorder_*`, `is_review_given, filter_(single_)preorder_product` |
| 4969-5013 | Search / Paymera | `search_placeholder_keywords, paymera_lang` |
| 5014-5126 | Impersonation | `start_impersonation, impersonator_user, acting_user(_id/_type)` |
| 5127-5291 | Deleted-order archive | `describe_order_item, archive_deleted_order` |
| 5292-5370 | Phones | `normalize_otp_phone, compose_e164_phone` |
| 5371-5572 | App popups | `app_popup_*` (7) |
| 5573-5659 | Seller flow | `combined_delivery_status, seller_post_login_redirect, seller_link_is_local` |
| 5660-5799 | Checkout / accountant / address | `checkout_confirmed_notices, can_settle_order_payment, accountants_settle_any_order, record_payment_settlement, order_accountant_id, clean_address_notes` |
| 5800-5910 | Catalogue writes | `unique_product_slug, category_ids_with_ancestors, sanitize_partner_html, strip_4byte_chars` |

## 7. Dead-code inventory (candidates; confirm before removing)

| Candidate | Evidence | Risk |
|---|---|---|
| Vendor updater `admin.php:152-158` + `UpdateController`, `RouteServiceProvider.txt`, `resources/views/update/`, `routes/update.php`, `routes/install.php`, `resources/views/installation/` | `install`/`update` are unmapped in `RouteServiceProvider`. Updates past 9.9.2 no longer apply to a forked codebase | Low |
| `DemoController` + `web.php:201-211` | Demo/migration one-offs for the CMS | Low |
| Debug routes: `web.php:99,130,212`; `api.php:23,71,90,111,115` | Test names (`send-notiii`, `test-*`, `dd('route works')`) | Low |
| Addon route files whose controllers are missing: `affiliate, auction, club_points, delivery_boy, offline_payment, otp, paytm, african_pg, pos, preorder, refund_request, seller_package, wholesale, cybersource` | Imported classes absent from repo and from all git history (§3.2). But **`Models/Preorder*` (21), `Helpers` 4747-4968, `resources/views/preorder`, and `addon_is_activated('otp_system')` branches** (e.g. `OrderController.php:1087`, `Auth/VerificationController.php:60`, which instantiates the missing `OTPVerificationController`) still reference them | Medium: first check the `addons` table and whether prod has the files |
| Unused gateways: `Payment/*` (Aamarpay, Authorizenet, Bkash, Instamojo, Iyzico, Mercadopago, Nagad, Ngenius, Payhere, Payku, Paymob, Paypal, Paystack, Razorpay, Sslcommerz, Stripe, Tap, Voguepay), `Api/V2/*` twins (+Flutterwave, Khalti, Myfatoorah, Paytm, Phonepe, Payfast), `Utility/{Nagad,Ngenius,Payhere}Utility`, `SSLCommerz.php`, views under `frontend/{bkash,instamojo,payhere,razorpay,voguepay,authorize_net,…}`, config `flutterwave/mercadopago/nagad/paystack/rave/toyyibpay/laravel-payku.php`, composer `paytm-wallet, authorizenet, instamojo, iyzipay, laravelrave, mercadopago, myfatoorah, paypal-checkout-sdk, razorpay, laravel-payku, stripe, laravel-paystack` | CLAUDE.md: only COD + Paymera are used. Their routes are still public (§3.3 C) | Medium: check the `payment_methods`/`business_settings` activation rows first |
| `Services/OTP/*` (Fast2sms, Mimo, Mimsms, Msegat, Nexmo, Sparrow, SslWireless, Twillo, Zender) + `twilio/sdk` | OTP runs on BuzzBip/Ishaar | Low–medium [unverified callers] |
| `Services/FrequentlyBroughtProductService.php` | Not referenced; `FrequentlyBoughtProductService` is the live one | Low |
| Unused `use Kreait\…` in `routes/api.php:13-15` | Package not installed | Low |
| Frontend themes `classic/metro/minima/reclassic` | Only one homepage theme is active (megamart per commits) [unverified: `business_settings.homepage_select`] | Medium |
| `config/error_log`, empty file `git`, `resources/views/frontend/inc/footer.blade.php.bak`, `bootstrap/.gitignore.zip`, `backend/test.blade.php` | Artefacts | Low |

## 8. Tests & how to run

- `vendor/bin/phpunit` (PHPUnit 10.5). `phpunit.xml` forces `APP_ENV=testing`, SQLite `:memory:`, array cache/session, sync queue, array mail. Two suites: **Unit** (7 files: BuzzBip template send, cart pricing, OTP sender/service, price rounding, product image fallback, example) and **Feature** (72 files).
- The Feature tests build their own minimal schema, since there is no full schema to migrate. Coverage follows Kapitano features: pricing / exchange type / order-rate snapshot, coupons (single-use, allowed users, product scope, combined-order, compensation, usage report), fake discounts, free shipping, app popups, notifications (audience, recipients, query count, device-token cleanup), seller PWA / push / WhatsApp alerts / order confirmation / unavailable / OTP login, staff password rotation, audit log, deleted-order archive, stock zeroing, partner store sync (`Http::fake`), WhatsApp campaigns, category visibility / bulk change, best-selling / home sections, product transfer / profit sort, Paymera app checkout / refund, address notes, email kill-switch.
- **Opt-in live-DB tests** (11, skipped unless `KAPITANO_RUNTIME_DB_TESTS=1`, run in a rolled-back transaction against local MySQL): `AdminReports, AuditLogPage, CancelledOrderTotals, CartRetargeting, CategoryVisibility, ExchangeType, NotificationDropdown, OrderFollowUp, SellerConfirmation, SellerWorkflow, StaffPasswordRotation` (`*RuntimeTest`).
- **Not covered**: mobile API contract / System-Key, most web controllers, vendor payment flows, route auth (nothing asserts the §3.3 issues), migrations against MySQL.
- Known failure: `ProductionNullRegressionTest::test_search_works_with_preorder_disabled_and_quote_in_query` (`no such table: search_queries`), per CLAUDE.md.
- No linter/formatter. No CI config in the repo (no `.gitlab-ci.yml`), so tests run only by hand.

## 9. Git workflow, branches, deploy

- Policy (CLAUDE.md): `main` = production, `dev` = integration, branch per task `feat|fix|perf/<slug>-<YYYY-MM-DD>` off `dev`, no worktrees.
- **Reality**: `origin/main` was last updated **2026-08-18** and is **356 commits behind `origin/dev`** (0 ahead). The newest commits on the servers (`testserver@kapitano.shop`, "Kapitano Server", "Hotgator VPS server") merge feature branches into `dev` **on the test server**, and sometimes edit code there ("ali test server edits 488"). So production most likely deploys from `dev` or a server-local state **[unverified]**.
- 390 commits, first on 2026-08-05. Main authors: mohammed aljaoor (214), alihythamali (107), testserver (30), Kapitano Server (22).
- 66 remote branches, most of them merged. **Not merged into `dev`**:

| Branch | Ahead | Content |
|---|---|---|
| `feat/exchange-rate-table-2026-09-23` | 1 | Stores exchange-rate changes in a DB table instead of the JSON files CLAUDE.md describes (rejected: "no prod DB change") |
| `feat/orders-coupon-filter-2026-09-26` | 1 | Only a merge commit (the feature itself is merged) |
| `fix/address-phone-fallback-2026-09-16` | 1 | Address without a phone falls back to the customer's phone |
| `fix/shared-cache-2026-08-15` | 3 | Redis cache/session for **two servers**, bounded TTLs instead of `rememberForever` models, `deploy-refresh.sh` (config/route/view cache + FPM reload) |
| `migrations` | 1 | 119 reverse-engineered `create_*` migrations of the prod schema (§5.1) |
| `perf/frontend-speed-2026-08-28` | 2 | Frontend speed + "delivers to Syria, not Jeddah" + WhatsApp button on every page |

- **Hosting facts**:
  - At first, shared cPanel hosting with the **project root as document root** (`config/error_log` paths `/home1/vujs***/public_html/...`, `.gitignore` entries `/index.php`, `vuj.srb.temporary.site/`). `public/.htaccess` now 301-redirects legacy `/public/...` URLs, so the doc root has since moved to `public/` (commit `4fbce729`, 2026-09-08, "Hotgator VPS server").
  - VPS migration around 2026-09-03 (`68483f8f`).
  - `.htaccess` passes `Authorization` and `X-XSRF-Token` headers, forces JSON on `apple-app-site-association` / `assetlinks.json`, and strips trailing slashes.
  - `public/index.php` sets `serialize_precision=-1`. The malware cleanup `e4ae0583` (2026-08-16, "remove malware and harden deployment") touched `index.php`, `.htaccess`, `uploads/.htaccess` and `CheckoutController`.
  - `.env` was committed once (`246e4a96`) and later removed. Treat every secret in history as leaked **[recommend rotation]**. `kapitano.json` (Firebase SA) is **still tracked**.
- No deploy script on `dev`. After deploy: run the migrations and/or hand-run SQL, then `php artisan cache:clear` on **each** server, then `scripts/check_permissions_exist.php`. If the caches are in use, re-run `php artisan optimize` (config + routes) and `view:cache` on each server after every deploy — a stale cache silently ignores route and `.env` changes. (`GET api/v2/send-notiii`, which wiped them, was removed 2026-10-21.) Both are safe since 2026-10-03 (§3.2, §5.4). Then `php artisan queue:restart` (the queue worker keeps the old code until it restarts, §5.5). Keep `storage/app/exchange_rate_history.json` and `exchange_rate_log.jsonl`.

## 10. Gotchas & open questions

1. **Security (§3.3 A)**: unauth zip-extract updater, unauth demo mutators, the System-Key-only `command-php` / `optimize:clear`, the hardcoded AI-import token, unauth Paymera cancel. None of these is covered by tests.
2. Fixed 2026-10-12: `api/v2/app/version` and `event-logo` ran `cache:clear` on every call. Production is one server with the file cache; adding a second one needs a shared cache store (redis / database) first, because cache keys are forgotten on the server that handled the save only.
3. `php artisan optimize` (config + route cache) is safe since 2026-10-03: route names are unique (§3.2) and the `env()` calls moved into the config files (§5.4). Keep it that way — `tests/Feature/RouteNamesAreUniqueTest.php` guards the names; a new `env()` outside `config/` is a bug.
4. `StartSession` is global and `EncryptCookies` is disabled: API calls create sessions, and cookies are plaintext.
5. System-Key failures and unknown API routes answer **HTTP 200** with `result/success:false`. Clients must check the body.
6. `getBaseURL()` and several controllers read `$_SERVER` directly, which breaks CLI/queue contexts (CLAUDE.md).
7. `filesystems.local` root is `public_path()`: `Storage::disk('local')` writes into the web root.
8. ~~`public/assets` (CSS/JS) is not in git.~~ **Closed 2026-10-09**: committed from the production copy; git is now the canonical copy.
9. The addon routes point at controllers that are not in git. Are the `affiliate / otp_system / preorder / pos / …` addons active on prod, and do their files exist there only? **Open.**
10. Which branch does production run, and is `main` still meaningful? What are the prod cache/session drivers (redis per the unmerged `b624dfd9`)? Is there a cron for `schedule:run`? **Open.**
11. What created prod table `poc_vuln_marker`, and when? Is it evidence of the incident that `e4ae0583` cleaned up? **Open, security.**
12. The path in CLAUDE.md (`C:\Users\Lenovo\Desktop\kapitano_web`) differs from this machine's checkout, so follow the rule, not the path. AGENTS.md is an older subset of CLAUDE.md, missing the hidden-categories, fake-discount, compensation-coupon, audience, report-filter and partner-store sections. Keep CLAUDE.md as the source.
13. `sqlupdates/` spans `v15`…`v992` (not `v15`…`v23`). `.gitignore` has `*.sql`, so a new hand-run SQL file needs `git add -f` or it silently won't ship.
14. `api_seller.php` has no seller-role middleware. Any customer token reaches seller endpoints unless the controller checks `user_type` (only some do) [unverified per endpoint].
15. `public/.well-known/` (`apple-app-site-association`, `assetlinks.json`) is in `.gitignore`: those files live on the server only and are uploaded by hand. The agreed copy of `apple-app-site-association` and the deep-link contract (paths, `kapitano://`, App Links tags `App\Utility\AppLinks`) are in `docs/api/deep-links/README.md`.
