# 04 — Auth, Users, Sellers, Staff & Permissions

> Domain map for engineers and AI agents. Written from a static read of branch
> `docs/codebase-map-2026-09-29` (= `origin/dev`) on 2026-09-29. Line numbers are for that
> commit. Nothing here was executed against a database or a running app. Claims marked
> **(unverified)** were inferred from code only and should be confirmed before relying on them.
> Security findings in section 8 are static-analysis results, **not** confirmed exploits.

---

## 1. Purpose & scope

This domain covers **who a request is** and **what it may do**:

- Customer sign-in/sign-up on the web (WhatsApp OTP is the primary path; email/phone + password
  from the stock CMS still exists), on the mobile API (Sanctum tokens), and inline at checkout.
- OTP generation, storage and delivery (BuzzBip / Ishaar WhatsApp providers, plus dead stock SMS
  providers).
- Social login (web + API), password reset, email verification, account deletion.
- User types (`customer`, `seller`, `staff`, `admin`, `delivery_boy`), profile and phone changes,
  addresses.
- Sellers and shops: registration, OTP login that auto-creates a shop, admin-created sellers,
  verification, the seller panel (`routes/seller.php`) and seller API (`routes/api_seller.php`),
  PWA, inventory, withdrawals/payouts, commission.
- Staff and roles (Spatie permission), forced staff password rotation, "login as" impersonation,
  the owen-it audit log.
- Middleware stack, `System-Key` API gate, rate limiting.

Out of scope (other docs): cart/checkout pricing, orders, payments, notifications.

Key mental model: **the phone number is the credential.** Almost every login resolves the account
with an exact `User::where('phone', $normalized)` match and there is no DB unique index you can rely
on for `users.phone` **(unverified — no migration defines it; `temp_auth` and most of `users` were
created outside `database/migrations`)**. Any code path that writes `users.phone` in a different
shape, or without verifying it, affects who can log in as whom.

---

## 2. Key files

| Path | Role | Notable functions (line) |
|---|---|---|
| `app/Http/Kernel.php` | Global stack, `web`/`api` groups, route middleware aliases | global stack 27-34 (StartSession is **global**), `web` 42-53 (EncryptCookies commented out), `api` 55-61, aliases 71-96 |
| `app/Providers/RouteServiceProvider.php` | Loads 20 route files, rate limiter | `map` 41-84, `configureRateLimiting` 389-394 (`api` = 600/min per user or IP) |
| `app/Http/Middleware/EnsureSystemKey.php` | Requires `System-Key` header on all `api` routes | `handle` 17-32; bypass list line 18 |
| `app/Http/Middleware/IsAdmin.php` | admin/staff gate + password-rotation redirect | `handle` 29-54, `passwordChangeRequired` 56-63 |
| `app/Http/Middleware/IsSeller.php` | seller gate (401 JSON / login redirect / ban / 404) | `handle` 17-58 |
| `app/Http/Middleware/IsCustomer.php`, `IsUser.php`, `IsUnbanned.php`, `IsAppUserUnbanned.php`, `CheckoutMiddleware.php` | other gates | see §5 |
| `app/Http/Controllers/HomeController.php` | **Primary web OTP login + registration** (customer and seller) and profile | `login1` 675, `sendOtpForLogin` 713, `sendOtpForSellerLogin` 764, `showOtpSellerForm` 810, `uniqueShopSlug` 825, `verifyOtpAndLoginSeller` 842, `showSellerRegister` 928, `sendOtpForLogin2` (resend) 937, `registerSeller` 993, `showOtpForm` 1123, `verifyOtpAndLogin` 1133, `sendCodeForWhatsApp` 1204, `register` 1265, `cart_login` 1433, `dashboard` 1472, `profile` 1489, `userProfileUpdate` 1504, `new_verify` 2028, `update_email` 2043, `email_change_callback` 2073, `reset_password_with_code` 2099, `sendRegVerificationCode` 2209, `regVerifyCodeConfirmation` 2283 |
| `app/Http/Controllers/Auth/LoginController.php` | Stock email/phone+password login (`/login`, used by admin/staff), social login, logout, account deletion | `redirectToProvider` 51, `handleAppleCallback` 64, `handleProviderCallback` 131, `validateLogin` 236, `credentials` 254, `authenticated` 267, `logout` 324, `account_deletion` 361 |
| `app/Http/Controllers/Auth/RegisterController.php` | Stock password registration (still routed via `Auth::routes`) | `create` 80, `register` 138, `registered` 263 |
| `app/Http/Controllers/Auth/ForgotPasswordController.php` | Sets `users.verification_code`, sends via email or OTP | `sendResetLinkEmail` 49 |
| `app/Http/Controllers/Auth/VerificationController.php` | Email verification, code-link login | `resend` 73, `verification_confirmation` 84 |
| `app/Http/Controllers/Api/V2/AuthController.php` | Mobile auth | `signup` 39, `register` 174, `sendOtp` 346, `sendCodeForWhatsApp` 401, `verifyOtp` 423, `resendCode` 515, `confirmCode` 541, `login` 565, `logout` 645, `socialLogin` 657, `guestUserAccountCreate` 745, `loginSuccess` 812, `account_deletion` 904, `getUserInfoByAccessToken` 921 |
| `app/Http/Controllers/Api/V2/PasswordResetController.php` | Mobile password reset | `forgetRequest` 17, `confirmReset` 57, `resendCode` 81 |
| `app/Http/Controllers/Api/V2/ProfileController.php` | Mobile profile (phone/password change) | `update` 52 (phone 65-85, password 111-113), `update_device_token` 140 |
| `app/Http/Controllers/CheckoutController.php` | Checkout phone verification login, guest account creation | `normalize_checkout_phone` 46, `can_login_at_checkout` 71, `match_account_by_phone` 88, `checkPhone` 121, `verifyPhone` 204, `checkout` 466, `createUser` 634 |
| `app/Http/Controllers/WhatsAppOtpController.php` | Stand-alone cache-based OTP send/verify (not wired to any login) | `send` 21, `verify` 113 |
| `app/Http/Controllers/IshaarOtpController.php` | Public "send this code to this phone" endpoint | `sendOtp` 25 |
| `app/Services/OtpSenderService.php` | **Single dispatch point** for OTP delivery; picks provider from `get_setting('otp_provider')` | `provider` 24, `sendOtp` 51, `sendThroughIshaar` 70 |
| `app/Services/OtpService.php` | Cache-stored hashed OTP (used only by `WhatsAppOtpController`) | `send` 26, `verify` 105, `wasRecentlySent` 143 |
| `app/Services/BuzzBipService.php` | BuzzBip WhatsApp API (OTP + templates) | `getToken` 49, `sendOtp` 108, `sendTemplate` 190, `resolveCountryCode` 679 |
| `app/Services/IshaarService.php` | Ishaar WhatsApp API | `sendOtp` 64, `normalizePhone` 144, `maskPhone` 152 |
| `app/Services/SendSmsService.php`, `app/Utility/SendSMSUtility.php`, `app/Services/OTP/*` | Stock CMS SMS providers (Twilio, Nexmo, …). Not used by current login flows | `SendSmsService::sendSMS` 9 |
| `app/Http/Helpers.php` | Global helpers | `isUnique` 2946, `isAdmin` 2981, `isSeller` 2991, `isCustomer` 3001, `seller_landing_route` 4278, `offerUserWelcomeCoupon` 4342, `start_impersonation` 5014, `impersonator_user` 5040, `acting_user` 5081, `normalize_otp_phone` 5292, `compose_e164_phone` 5331, `seller_post_login_redirect` 5599, `seller_link_is_local` 5622, `clean_address_notes` 5779 |
| `app/Models/User.php` | Authenticatable, `HasApiTokens`, `HasRoles`, `Auditable`, `MustVerifyEmail` | `$auditExclude` 22, `booted` (password stamp/history) 42, `$fillable` 111, `shop` 149, `seller` 153, `staff` 159, `isAccountant` 286, `accountants` 301 |
| `app/Models/TempAuth.php` | `temp_auth` table (phone, otp) — no model logic, `$guarded = []` | — |
| `app/Models/Shop.php` | Shop; `hasCompletedProfile` decides seller landing page | `hasCompletedProfile` 64 |
| `app/Models/PermissionRole.php` | Spatie Role subclass, audited; used by `RoleController` for writes | `syncPermissionsAudited` 22 |
| `app/Models/Role.php` | Separate read model (translations) on the same `roles` table | — |
| `app/Providers/AuthServiceProvider.php` | `Gate::before` → role `Super Admin` passes every check | `boot` 24-32 |
| `app/Http/Controllers/StaffController.php` | Staff CRUD (user_type `staff` + `staff` row + Spatie role) | `store` 54, `update` 110, `destroy` 139 |
| `app/Http/Controllers/RoleController.php` | Role CRUD, ad-hoc permission creation | `store` 57, `update` 102, `destroy` 127, `add_permission` 140 |
| `app/Http/Controllers/Admin/StaffPasswordController.php` | Forced "password expired" form | `expired` 18, `update` 35 |
| `app/Services/StaffPasswordPolicy.php` | Rotation policy (settings-driven) | `appliesTo` 78, `isExpired` 114, `reuseError` 150, `tooSimilar` 177 |
| `app/Http/Controllers/CustomerController.php` | Admin customers, "login as customer" | ctor perms 15-29, `store` 162, `login` 314, `ban` 328 |
| `app/Http/Controllers/SellerController.php` | Admin sellers, approve/reject, commission, "login as seller" | ctor perms 22-45, `store` 110, `update` 226, `approve_seller` 343, `reject_seller` 361, `login` 427, `ban` 441, `setSellerBasedCommission` 499, `setSellerCommission` 514 |
| `app/Http/Controllers/ShopController.php` | Stock seller self-registration (`/shops`), registration-code flow | `create` 46, `store` 102, `sendRegVerificationCode` 200, `regVerifyCodeConfirmation` 257 |
| `app/Http/Controllers/Seller/*` | Seller panel (web) | `ProfileController::update` 31, `ShopController::update` 21 / `verify_form_store` 112, `InventoryController::index` 18 / `updateStock` 64, `PwaController::storePushToken` 303, `SellerWithdrawRequestController::store` 33 |
| `app/Http/Controllers/Api/V2/Seller/*` | Seller API (mobile) | `WithdrawRequestController::store` 30, `SellerPackageController::purchase_free_package` 22 |
| `app/Http/Controllers/SellerWithdrawRequestController.php`, `CommissionController.php` | Admin payouts | `CommissionController::pay_to_seller` 25, `seller_payment_done` 70, `calculateCommission` 117 (on payment, each live line with `order_details.purchase_price` adds that purchase price to `admin_to_pay`, whatever the payment type; commission = `price − purchase_price`; tax, shipping, coupon and lines without a cost add nothing; cancelled / returned / deleted lines take it back through `App\Utility\SellerBalanceUtility`, hooked on `OrderDetail`) |
| `app/Http/Controllers/Admin/SellerPricingController.php`, `app/Services/SellerPricing/*` | Seller Commission page (markup / discount / none tabs, category %, move with "all / new only / per category") and the product-list price button (its table also edits the seller price per variant, in every mode: `SellerPricingWriter::setSellerPrices()`) | routes `seller_pricing.*`, `products.pricing(.save)`; the old `vendor_commission` / `seller_based_commission` / `categories_wise_commission` pages redirect to it once the columns exist |
| `app/Http/Controllers/Admin/SellerEntryTypeController.php`, `app/Services/SellerEntryType.php` | Seller Entry Type page (self-entry / entered by staff via "log in as seller" / unclassified), automatic rule on `Product::created`, audit-log backfill, `entry_type` report filter | routes `seller_entry_type.*`; `ReportFilterService::applyEntryType()` |
| `app/Http/Controllers/AddressController.php`, `Api/V2/AddressController.php` | Addresses | web `store` 39, `update` 97, `destroy` 125, `set_default` 160; API `updateShippingAddress` 44, `deleteShippingAddress` 79, `makeShippingAddressDefault` 95, `updateAddressInCart` 108 |
| `app/Http/Controllers/Admin/AuditLogController.php`, `app/Resolvers/ActingUserResolver.php`, `ImpersonationTagResolver.php`, `config/audit.php` | Audit log | controller gate 33-40 (admins only) |
| `routes/web.php` | Customer/seller login routes | checkout OTP 113-122, Ishaar 126-144, WhatsApp OTP 171-179, `Auth::routes` 234-236, LoginController 269-277, registration-code flows 293-307, seller OTP 314-331, customer OTP 361-378, `user` group 526-535, `customer` group 562, `auth` group 630-667 |
| `routes/api.php` | Mobile auth routes | `v2/send-otp` 21, `v2/verify-otp` 189, `v2/auth/*` 192-216, `guest-user-account-create` 229, `app_user_unbanned` group 307 |
| `routes/seller.php`, `routes/api_seller.php`, `routes/seller_package.php`, `routes/otp.php`, `routes/admin.php` | see §5 | — |

---

## 3. Data model

There is no complete schema in the repo. `users` is built from the 2014 stock migration plus vendor
`sqlupdates/v*.sql` (e.g. `v32.sql` adds `new_email_verificiation_code` and `verification_code`)
plus Kapitano migrations. Columns below are those the code reads/writes.

**`users`** (model `User`)
- Identity: `id`, `name`, `lname`, `email` (unique in stock migration), `phone` (canonical shape
  `+<dial><national>`, e.g. `+9639…`; legacy rows exist in `963…`, `00963…`, `09…`, bare
  national — see `CheckoutController::match_account_by_phone` 88-119), `birthday`, `gender`,
  `avatar`, `avatar_original` (upload id).
- Type/state: `user_type` ∈ {`customer`, `seller`, `staff`, `admin`, `delivery_boy`} (DB default
  presumably `customer` **(unverified)**), `banned` (0/1/null), `email_verified_at`.
- Secrets: `password` (nullable in practice — OTP and guest accounts have none), `remember_token`,
  `verification_code` (4 digits, reused for email verification, phone verification, *and* password
  reset), `new_email_verificiation_code` (sic), `password_changed_at`, `password_history` (JSON
  array of old hashes; migration `2026_09_19_000002`), social `provider`, `provider_id`,
  `access_token`, `refresh_token`, `device_token`.
- Location: `address`, `country_id`, `state_id`, `city_id`, `postal_code`.
- Referral: `referral_code` (16 chars, generated after save in 5 places), `referred_by`.
- `$auditExclude` (User.php 22-27) keeps secrets and `device_token` out of `audits`.

**`temp_auth`** (model `TempAuth`) — `phone`, `otp` (4-digit string), timestamps. One row per phone
(`updateOrCreate(['phone'=>…])`). **No expiry column and no attempt counter**; rows are deleted
only on a successful match. No migration or SQL file creates it (**created by hand in production —
unverified schema**).

**Cache-based OTP** (`OtpService`): keys `whatsapp_otp:{digits}` (bcrypt hash, TTL
`services.buzzbip.otp_expire`, default 600 s) and `whatsapp_otp_sent:{digits}` (60 s).

**`registration_verification_codes`** (`sqlupdates/v980.sql`): `email`, `phone`, `code`,
`is_verified`. Used by the stock "verify before register" flows in `HomeController` and
`ShopController`.

**`shops`** (model `Shop`, eager-loads `user`): `user_id` (1:1 with users), `name`, `slug` (public
URL, `uniqueShopSlug` only in the OTP flows), `address`, `phone`, `meta_title`,
`meta_description` (profile-complete check), `verification_status`, `verification_info`,
`admin_to_pay` (payout balance), `commission_percentage` (the seller's markup / discount %,
with `pricing_mode` = `markup` / `discount` / NULL — see "Seller pricing" in CLAUDE.md and
`app/Services/SellerPricing/`),
`entry_type` (`self` / `managed` / NULL = unclassified, with `entry_type_set_by` = the staff who
placed the seller by hand, NULL = automatic, and `entry_type_set_at`; migration
`2026_10_07_000001`, see `App\Services\SellerEntryType`),
`cash_on_delivery_status`, `bank_*`, `seller_package_id`, `order_level`
(migration `2026_09_09_000001`), `show_hidden_product_fields` (`2026_09_10_000001`).

**`sellers`** (model `Seller`, eager-loads `user`, `user.shop`): legacy stock table
(`seller_package_id`, payments). Most Kapitano code uses `shops` + `users.user_type='seller'`;
the OTP seller flows never create a `sellers` row.

**`staff`** (model `Staff`, audited): `user_id`, `role_id` (duplicate of the Spatie role link),
`pick_up_point` relation.

**`addresses`** (model `Address`, `$fillable = ['set_default']`): `user_id`, `address`,
`country_id`, `state_id`, `city_id`, `postal_code`, `phone`, `longitude`, `latitude`,
`set_default`, `notes` (migration `2026_09_22_000001`, cleaned by `clean_address_notes()`).

**Spatie permission tables** (`config/permission.php`): `roles`, `permissions` (Kapitano adds a
`section` column used to group the Roles UI), `model_has_roles`, `model_has_permissions`,
`role_has_permissions`. Role id **1 = "Super Admin"** (hidden from lists by `where('id','!=',1)`).
Cache key `spatie.permission.cache` (24 h, auto-flushed by Spatie on role/permission writes).

**Other auth-adjacent tables**: `personal_access_tokens` (Sanctum, `expiration => null` in
`config/sanctum.php:47` — tokens never expire), `seller_push_tokens`, `seller_withdraw_requests`
(`user_id` — see §6 item 11 for inconsistent meaning), `register_invitations`, `user_loyalties`,
`audits` (migration `2026_09_17_155116`).

---

## 4. Flows (step by step)

### 4.1 Web customer WhatsApp-OTP login / registration (primary storefront path)
1. `GET /users/login` → `HomeController::showPhoneLoginForm` 707 (`auth.phone_login`).
2. `POST /dashboard/login/phone` (`user.phone.submit`, web.php:371) → `sendOtpForLogin` 713:
   normalise via `normalize_otp_phone` 718 → validate 10-20 chars → 4-digit OTP from `rand()` 733 →
   `TempAuth::updateOrCreate` 737 → `sendCodeForWhatsApp` 1204 → `OtpSenderService::sendOtp` →
   BuzzBip or Ishaar → session `otp_login_phone/country_code/context=customer` 747.
3. Resend: `POST /dashboard/login/phone/resend` → `sendOtpForLogin2` 937 (same logic, context from
   request/session).
4. `GET /dashboard/login/otp` → `showOtpForm` 1123.
5. `POST /dashboard/login/otp/verify` → `verifyOtpAndLogin` 1133: normalise, `TempAuth` lookup 1159,
   string compare 1161. If a user with that phone exists (**any `user_type`**, 1163) and not banned →
   `Auth::login($user, true)` 1171 → merge guest cart 1172-1181 → delete TempAuth → sellers go to
   `seller_post_login_redirect` 1189, everyone else `customer_post_login_redirect('home')` (see §4.1a). If no user → delete TempAuth → redirect to
   `user.registration` with `phone` flashed 1196.
6. `GET /users/registration` → `showRegister` 1256; `POST /dashboard/register` (`register`) →
   `HomeController::register` 1265: referral/loyalty grant 1271-1310, normalise + validate
   E.164/unique 1328-1341, create `customer` 1344-1352, `RegisterInvitation` 1363, referral code
   1373-1376, `Auth::login` 1416, cart merge 1417-1426. **It does not check that the phone was ever
   OTP-verified** (see §8).

### 4.1a Back to the page after signing in

A guest asked to sign in lands back on the page they were on, never on home when one is known. One
session key, `link`, holds it; `customer_post_login_redirect($fallback)` (Helpers) uses it once
(`session()->pull`) and only when `customer_return_link_is_safe()` accepts it: this host or a bare
path, http(s), no `//host`, no backslash or control characters, and none of
`CUSTOMER_RETURN_LINK_REFUSED` (login/registration/OTP/password/social pages, `/admin`, `/seller`,
`/api`, delivery boy, service workers) - so it can never send anyone off-site or into a loop.

- **Who writes it**: the two login popups (`frontend/partials/login_modal`, `auth/login_dialog`)
  post `redirect_to` (partial `auth.partials.login_return_field` = `login_return_link_for_page()`),
  kept by `sendOtpForLogin` before any check; `showPhoneLoginForm` takes `?redirect=`, else the
  page the customer came from (`url()->previous()`) unless a guard just set it (flash
  `link_from_guard`); the guards (`IsCustomer`, `IsUser`, `CheckoutMiddleware`, `Authenticate`
  for storefront paths, and the three guest-checkout-off redirects in `CheckoutController`) call
  `remember_login_return_link_for_guard($request)`: the page itself for a plain GET, else - a POST,
  AJAX, or a GET action such as `followed_seller.store` / `purchase_history.destroy` (route names
  ending `store|remove|destroy|set_default|re_order`) - the page it was pressed on, so nothing is
  replayed. An unsafe value never overwrites a good one.
- **Who reads it**: `verifyOtpAndLogin` (and its double-post branch, which reuses `link_used` so
  both answers agree), `register`, `RegisterController::registered`, `LoginController::authenticated`
  (fallback `dashboard`) and both social callbacks. Sellers keep `seller_post_login_redirect`
  (seller pages only); admins keep `admin.dashboard`.
- The wishlist heart opens the login popup for a guest (`addToWishList` in `layouts/app`) like the
  cart, bid, chat and review buttons. `HomeController@product` still sets the link on every guest
  product view (older behaviour, harmless: every entry above overrides it).

Pinned by `tests/Feature/LoginReturnToPageTest.php` (real routes, SQLite).
### 4.2 Web seller OTP login / registration
1. `GET /seller/login` → `HomeController::login1` 675 (`seller_new_login` view).
2. `POST /dashboard/login/seller/phone` → `sendOtpForSellerLogin` 764 (copy of 713, context
   `seller`) → `GET /dashboard/login/seller/otp` → `showOtpSellerForm` 810.
3. `POST /dashboard/login/seller/otp/verify` → `verifyOtpAndLoginSeller` 842: TempAuth match 865-867;
   only the number's **seller** account (`AccountRoles::scope(..., 'seller')`, ADR 0010) → ban check →
   create a shop if none (`uniqueShopSlug`) → `AuthGuards::login(remember)` (seller guard only) →
   `seller_post_login_redirect` (Helpers: dashboard + `session('link')` only if profile complete and
   link passes `seller_link_is_local`; otherwise `seller.shop.index`). A number with no seller account
   (a customer's or staff member's included) goes to `seller.registration`, which opens a seller
   account **beside** it. Until 2026-11-09 a customer found here was promoted to seller
   (`php artisan accounts:converted-sellers` counts those accounts).
4. Unknown phone → `seller.registration` → `showSellerRegister` 928 → `POST /dashboard/seller/register`
   → `registerSeller` 993: normalise/validate 1013-1026, create `seller` user 1029-1034 + shop with
   `uniqueShopSlug` 1037-1042, referral code, `Auth::login` 1102, redirect `seller.shop.index`.
   Again no proof of OTP.

### 4.3 Stock password logins (still live)
- `POST /login` (`Auth::routes`, web.php:235) → `LoginController` (trait `AuthenticatesUsers`, which
  includes Laravel's `ThrottlesLogins`: 5 attempts/min per email|IP). `credentials` 254 builds
  `+{country_code}{phone}` or uses email. Since 2026-11-09 `LoginController::guard()` is the `admin`
  guard, whose provider reads admin / staff rows only: this is the **admin and staff login only**
  (`guest:admin`); a customer or seller there gets "invalid" plus a pointer to the phone sign-in.
  `authenticated` → `admin.dashboard`.
- `POST /users/login/cart` → `HomeController::cart_login`: customer accounts only, by phone or email
  + `Hash::check`. **No throttle.**
- `POST /register` (`Auth::routes`) → `RegisterController::register` 138 → `create` 80 (email, or
  phone when addon `otp_system` is on; otherwise `$user` is undefined → error). `registered` 263
  redirects phone users to `route('verification')`, whose controller does not exist (§8).
- API `POST /api/v2/auth/login` → `AuthController::login` 565: `login_by` + `user_type`
  (customer default, or seller/delivery_boy), email **or** phone equality, `Hash::check`, ban check,
  `loginSuccess` 812 (creates a new Sanctum token each time).

### 4.4 Mobile API OTP login / registration
1. `POST /api/v2/send-otp` (api.php:21, `api` group → `System-Key` + 600/min) →
   `AuthController::sendOtp` 346: `normalize_otp_phone`, 4-digit `rand()` OTP, TempAuth, send.
   `normalize_otp_phone` also drops a dial code typed twice (`963 963 995 624 470` → `963995624470`, since 2026-10-03; BuzzBip refused such numbers with "to is not a valid phone number") - only when 8+ national digits are left, so a real `+963 963 xxx xxx` keeps both. Pinned by `OrderPageNullSafetyTest::test_phone_normalizer_drops_a_dial_code_typed_twice`.
2. `POST /api/v2/verify-otp` (api.php:189) → `verifyOtp` 423: TempAuth `where phone & otp` 448,
   delete 460 (expiry check commented out at 457-458), pick user by requested `user_type`
   (customer/seller/delivery_boy) 463-472, ban check, cart merge, `loginSuccess` 490. No user →
   `{result:true, action:'register', phone}` 492-497.
3. `POST /api/v2/auth/register` → `register` 174: referral 181-211, normalise/validate 234-259,
   **`user_type` taken from the request** (`in:customer,seller,delivery_boy`) 245/275, auto-verify
   phone-only accounts 298-306, cart merge, token 328, `loginSuccess`. No proof of OTP; a
   self-declared seller gets **no shop row**.
4. Legacy `signup` 39 (password + `verification_code`, sent via `OtpSenderService` 149) and
   `resend_code` 515 / `confirm_code` 541 (auth:sanctum) remain routed.
5. `POST /api/v2/guest-user-account-create` → `guestUserAccountCreate` 745: creates a password-less
   user + address for any phone (no uniqueness check), merges cart, returns a token.
6. `POST /api/v2/auth/info` → `getUserInfoByAccessToken` 921: token in body → full `loginSuccess`
   payload.

### 4.5 Checkout phone verification (web)
`resources/views/frontend/checkout.blade.php:1021/1090` call:
1. `POST /checkout/check-phone` → `CheckoutController::checkPhone` 121: validate phone/ISO
   country/dial code → `match_account_by_phone` 88 (exact match over 5 legacy shapes; prefers an
   orderable customer; anything else = `blocked`) → blocked numbers get no code 137-144 →
   `random_int` OTP → TempAuth (canonical phone) → session `checkout_phone` → send; on provider
   failure TempAuth and session are rolled back 166-175 → saves `GuestCartInfo` for guests.
2. `POST /checkout/verify-phone` → `verifyPhone` 204: Arabic digits → ASCII, phone from request
   (session fallback), TempAuth `phone+otp` 240, delete, re-match, blocked → refuse 270-282,
   customer → `Auth::login($user)` 290 + cart merge; unknown number → `{success:true}` only.
3. `POST /checkout/payment` → `checkout` 466: guests → `createUser` 634 (new customer with the
   **form's** phone, `max:13`, no normalisation, no uniqueness, no server-side proof that
   step 2 succeeded) → `auth()->login` 712.
Tests: `tests/Feature/CheckoutOtpVerificationTest.php`.

### 4.6 Other OTP providers/endpoints
- `OtpSenderService::sendOtp` 51 is the only function any login flow calls. Provider =
  `get_setting('otp_provider')` falling back to `config('services.otp_provider')`, then `buzzbip`.
  Ishaar refusals are converted to exceptions 70-83.
- `POST /auth/whatsapp/send-otp` / `verify-otp` (web.php:171-179) → `WhatsAppOtpController` using
  `OtpService` (hashed, expiring, per-phone `RateLimiter` 3/60 s, 60 s resend guard). **Verify only
  returns JSON; nothing logs in.** No view references these routes (grep of `resources/`), so it is
  effectively an unused, better-designed parallel implementation.
- `POST /otp/whatsapp/send` → `IshaarOtpController::sendOtp` 25 sends a **caller-supplied** 6-digit
  code to a caller-supplied phone. `GET /test/whatsapp-otp` (web.php:130) wraps it with a
  hard-coded default phone number. Both public.
- `routes/otp.php` (stock addon) points to `OTPVerificationController`, `OTPController`,
  `SmsController`, `SmsTemplateController` — **none of these classes exist** in `app/`.

### 4.6a OTP code lookup for customer service (2026-10-28)
A customer whose WhatsApp code never arrives can ask customer service for it. Admin → Customers →
"OTP code lookup" (`admin.otp_codes.index`, `Admin\OtpCodeLookupController`, everything in
`App\Services\OtpCodeLookup`, whose docblock is the map). Two permissions (Staff Roles section
"otp_codes"), **granted to no role** by migration `2026_10_28_000001` - the Super Admin gives them to the
few employees who need them, because whoever has the code can open the account: `view_otp_codes`
(the lookup form, the only way to a code) and `view_otp_code_log` (the log table). Either opens the page.
- The employee types the number; `lookup` (JSON) answers at once with the account(s) holding it in any
  spelling (`AccountDeactivationUtility::phoneVariants()`) and every code waiting, with its age and
  whether it expired or the number is locked. No identity check is asked (decided 2026-10-28).
- Every lookup writes an `otp_code_views` row first (employee, number, account, number of codes
  shown, ip; **never the code**), listed on the same page. 30 lookups an hour per employee
  (`LOOKUPS_PER_HOUR`), plus a route throttle.
- Codes read (never written, so showing one changes nothing for the customer): `temp_auth` through
  `OtpCode::find()` (expired / locked flags from `OtpGuard`), `registration_verification_codes`
  (not verified yet) and `users.verification_code` (only when it is 4-6 digits; an e-mail token is
  stored there too). `WhatsAppOtpController`'s cache keeps only a hash and is not shown.
Pinned by `tests/Feature/OtpCodeLookupTest.php`. Deploy: `php artisan migrate` (table, permission,
Arabic strings), then `php artisan cache:clear`.

### 4.6b Customers list: Filters window, Excel export and import (2026-11-03)
Admin → Customers (`CustomerController@index`, `view_all_customers`) has a search box (name, e-mail,
phone in any shape, `#id`) and a **Filters** button opening a window like the orders list's, with
removable chips above the table. Everything is read by `App\Services\Customers\CustomerListFilters`
(its docblock is the map): who ordered / how many / how much / from which categories and brands,
governorate, city, join date, gender (incl. "not recorded"), birthday month, age, app, wishlist are
**the marketing filters** (`CustomerSegmentUtility`, same `aud_*` field names), so a list and a
campaign mean the same thing; the list adds account status, verification, banned, suspicious, account
opened from (app / website / not recorded — `traffic_attributions`, since 2026-09-30), birthday /
e-mail recorded, cart, wallet and the sort. The old `from_date` / `to_date` links still work.
- **Export** (`customers.export`, URL `/admin/export`): every customer the filters show, with
  `CustomerExcel::exportColumns()` (gender, birthday, age, governorate / city of the default address,
  addresses, registered at / from, statuses, app, orders / cancelled checkouts, spent in SYP at each
  order's rate, last order, wallet, wishlist, cart, referral code), 1000 customers per batch at a
  fixed number of queries. Text cells are written as text, or "+963…" would become a number.
- **Import** (`Admin\CustomerImportController`, `edit_customer`; a new customer also needs
  `add_customer`): upload → preview (cache, 2 h, per uploader) → save. `App\Services\Customers\CustomerExcel`
  (docblock = file rules): columns found by heading (Arabic or English), ID finds the customer, else
  the phone (a phone nobody has = a new customer); name, phone, e-mail, gender, birthday are written,
  an empty cell never clears, "only fill empty fields" keeps what the customer has. Only what a line
  changes is judged, so an untouched export reads back all "unchanged" (checked on 2,000 local
  customers). Save re-reads the file against the database and writes only lines whose change is still
  the previewed one (fingerprint), through Eloquent.
Pinned by `tests/Feature/CustomerListFiltersExcelTest.php`. Deploy: `php artisan migrate` (copies the
Arabic strings from `translations.json`), then `php artisan cache:clear`.

### 4.7 Password reset / email verification / email change
- Web: `POST /password/email` → `ForgotPasswordController::sendResetLinkEmail` 49 sets
  `verification_code` and emails it or sends via OTP → `POST /password/reset/email/submit`
  (`password.update`, web.php:308, overrides the `Auth::routes` name) →
  `HomeController::reset_password_with_code` 2099 (`email` + `verification_code` match) → set
  password, `auth()->login`, admin/staff → admin dashboard.
- `GET /verification-confirmation/{code}` → `VerificationController::verification_confirmation` 84:
  any user with that `verification_code` is **logged in**.
- `GET /email-change/callback` → `HomeController::email_change_callback` 2073.
- API: `forget_request` 17 → `confirm_reset` 57 (matches `verification_code` **alone**) →
  `resend_code` 81.

### 4.8 Social login
- Web: `GET /social-login/redirect/{provider}` → `redirectToProvider` 51;
  `GET /social-login/{provider}/callback` → `handleProviderCallback` 131 (stateless except
  Twitter): match `provider_id` → else link to existing account with the same email → else create.
  `POST /apple-callback` → `handleAppleCallback` 64.
- API: `POST /api/v2/auth/social-login` → `socialLogin` 657: validates the client's provider token
  via Socialite, but the existing-account lookup uses the **client-supplied `provider`** field
  (704), not the id returned by the provider (§8).

### 4.9 Profile & phone change
- Web customer: `POST /user/update-profile` → `HomeController::userProfileUpdate` 1504 — writes
  `phone` raw 1518, password without current password 1522-1524.
- Seller: `POST /seller/profile/update/{id}` → `Seller/ProfileController::update` 31, id pinned to
  `auth()->id()` 41-43; `SellerProfileRequest` normalises (`compose_e164_phone`) and enforces
  uniqueness; no OTP.
- API: `POST /api/v2/profile/update` → `Api/V2/ProfileController::update` 52 — normalises, regex,
  uniqueness 65-85; email trimmed, format-checked and refused when another account holds it
  (`result: false`, "An user exists with this email") — before 2026-10-15 a taken address hit
  `users_email_unique` and answered a 500; password without current password; no OTP.
- Admin own profile: `ProfileController::update` (`admin/profile/{id}`) — same email rule as
  `SellerController::update` (nullable, `unique:users,email,<id>`, '' stored as NULL).
  Pinned by `tests/Feature/ProfileEmailDuplicateTest.php`.
- Admin: `SellerController::update` 226 (normalised + unique), `CustomerController::update` 281,
  `StaffController::update` 110 (raw `mobile`).

### 4.10 Seller onboarding summary
Three independent creators of a seller (see §6 item 6; the OTP login no longer promotes customers
since 2026-11-09, ADR 0010): OTP registration
(4.2), stock `/shops` form (`ShopController::store` 102, password-based, slug not de-duplicated),
admin "Add seller" (`SellerController::store` 110). Verification: seller submits
`Seller/ShopController::verify_form_store` 112 → admin `sellers.approve`/`reject`
(`SellerController` 343/361) set `shops.verification_status`; the `Shop` hook retires the caches (§4.10d).
Landing page after login: `seller_landing_route` (Helpers 4278) = dashboard only when
`Shop::hasCompletedProfile()`.

Admin → All Sellers (`SellerController::index`, 2026-11-02): the search box goes through
`searchShops()` - the shop name the list shows (37 of 72 shops are named differently from
their owner's account, which was all it searched), the owner's name, email and phone, plus a
phone typed the local way ("0937 158 233", Arabic digits) without its dial code / trunk zero,
since phones are stored `+963…`. Enter in the box is submitted by script: the account
manager filter's live-search box is a second text field in the form, and with two the
browser submits nothing. With every column shown the name cell keeps 170px and wraps
(`.kp-sellers-table`), long emails wrap - no horizontal scroll. Pinned by
`tests/Feature/AdminSellerListSearchTest.php`.

Filters and sort (2026-11-10, `listFilters()` / `filterShops()` / `sortShops()`): besides status,
account manager and search, the list filters by entry type (`SellerEntryType::constrain()`),
products (`products` = some | published | none), balance (`balance` = owed: `admin_to_pay > 0`,
owes: `< 0`, zero) and commission option (`pricing` = a `SellerPricing::MODES` value or none).
`sort` = newest (default) | oldest | products_desc/asc | balance_desc/asc | name; the
"Number of products" and "Seller balance" headers toggle their sort. Products sort by every
product the seller owns (the number shown; the published count is shown under it); the balance
sorts by its worth in the default currency (`balanceInDefaultSql()`: one `CASE` branch per other
`shops.price_currency`), since each seller's balance is in their own currency. Columns: shop name
(+ entry type, joined date), phone and email in one cell, products, balance (owed / owes us),
commission, status (+ the verification request link, formerly its own column), the advanced
product fields switch; "Email Verification" was dropped (every seller account has it). Pinned by
`tests/Feature/AdminSellerListFiltersTest.php`.

Phone, GPS and Excel (2026-11-11): `phone` = short (the **account** phone `users.phone`, the
sign-in and the number shown, has fewer than 10 digits once spaces, `+ - ( ) . /` are taken out;
an empty phone counts as short) | ok; `gps` = none (the shop's own pickup point
`shops.delivery_pickup_latitude/longitude`, its main branch, is missing or 0,0; extra
`shop_branches` are not looked at) | set. SQL and the row markers count the same way
(`SellerController::phoneIsShort()` / `shopHasGps()`). "Export to Excel" (`?export=xlsx` on
`sellers.index`, `exportShops()`) writes every seller the filters and sort select, not one page
(`App\Exports\SellerListExport` = `ReportSheetExport` with the phone column kept as text so
`0944…` keeps its 0): id, shop, account name, phone + check, email, GPS + coordinates, entry type,
account manager, joined, products / published, balance in the seller's currency + currency + in
SYP (`SellerCurrency::toSypAmount()`), commission, status, open warnings.

### 4.10d Seller on sale: banned / un-approved sellers vanish for customers (2026-11-02)

Term in `CONTEXT.md` ("Seller on sale"); the full behaviour is in
[`features/seller-on-sale.md`](features/seller-on-sale.md). A seller is on sale while their shop is approved
(`shops.verification_status = 1`) **and** their account is not banned (`users.banned = 0`);
staff take one off with Ban (`SellerController@ban`, which also un-approves the shop) or the
Approved switch / reject. Only an on-sale seller's products reach customers; the products
themselves are never changed (still published, still in the seller's panel, cart rows hidden
not deleted), so turning the seller back on brings everything back as it was. Before, the lists
hid them but the product link, quick view, variant price, cart, checkout, flash deals, home
category rows, the app's product details / shop lists / coupon list and the product feed did not.

- **One rule, read from a cached id list** (no query per product): `verified_sellers_id()` =
  approved shops whose user is not banned; `where_seller_on_sale($query, $table)` /
  `Product::sellerOnSale()` for queries (`filter_products()` is built on it),
  `product_seller_on_sale($product)` for one loaded product. Both also apply the CMS switch
  `vendor_system_activation` (off = in-house products only). A new customer path that shows or
  sells a product must use one of them.
- **Hooks, not callers, react to a change**: `Shop::saved` (verification_status changed) and
  `User::updated` (a seller's `banned` changed) call `seller_on_sale_changed()`:
  `forget_product_caches()` (lists, `verified_sellers_id`, the catalogue version behind every
  cached app answer and the product feed) and queues the seller's waiting back-in-stock requests
  (`StockAlerts::queueFor()` re-checks the seller). Any screen that bans or approves is covered.
- **Where it is applied**: web product page (404), quick view (`productUnavailableCart`), web
  variant price (`in_stock = 0`), compare (left out), flash deals (`get_flash_deal_products()`,
  `/flash-deal/{slug}`, app `flash-deals` / `flash-deal-products`), home category rows
  (`get_cached_products()`), add to cart (web + app), `CartProductOnSaleScope` (so the cart,
  checkout and order placement), app `products/{slug}/{user_id}` (also published + approved now;
  off = `{"data": [], "success": true}` like an unknown slug), app `products/variant/price`, app
  `GET products` (apiResource index/show), app shop product lists and `products/seller/{id}`,
  app coupon product list, `/export/product`, `StockAlerts::onSale()`.
- **Approving a banned seller is refused** (`updateApproved` answers `{message}`, the switch
  flips back; `approve_seller` flashes an error): unban first.
- Not changed: the seller's profile page `/shop/{slug}/{type}` (an un-approved shop's still
  opens as before, decided 2026-11-02; a banned shop's is a 404, as before), orders already placed, the seller's balance and panel, admin screens, preorder
  (already checked), Ban still a GET link. Known separately broken: app
  `shops/products/all/{id}` answers 500 for any shop with products (`ProductCollection` names
  the missing route `products.related`, also in `SearchProductCollection`), and api routes
  `subCategory` / `subSubCategory` / `newFromSeller` point to missing methods.

Pinned by `tests/Feature/SellerOnSaleTest.php` (SQLite), cases in `StockAlertsTest`, and the
opt-in `SellerOnSaleRuntimeTest` (real admin ban / approve routes, then every web and app path
on MySQL, rolled back).

### 4.10b Seller price currency (2026-10-25)
Each seller prices in one currency (`shops.price_currency`, NULL = USD), set by staff on the Seller Commission page ("Currency" column → "Change currency", permission `set_seller_currency`, preview then `SellerCurrencySwitch::run()`; undo with `php artisan seller-currency:restore`). The seller's forms, orders, invoice, dashboard, balance, payouts, withdraw requests, commission history and app answers are in that currency (`SellerOrderAmounts::money()` / `mine()`, order lines frozen in `order_details.seller_amount`); customers and staff read SYP. Map: CLAUDE.md "Seller price currency" and the docblock of `App\Services\Currency\SellerCurrency`; app contract `docs/api/seller-currency.md`.

### 4.10a Seller dashboard (2026-10-24)
`GET /seller/dashboard` (`seller.dashboard`) → `Seller\DashboardController::index` →
`App\Services\SellerDashboard` (static, one method per part; its docblock is the map) →
`seller/dashboard.blade.php` + `seller/dashboard/styles.blade.php`. It replaced the three-shortcut page
of 2026-09-08 (which itself replaced the CMS page that summed `orders.grand_total`, our selling prices).

- **The three main buttons come first** (Create Product, Update Stock - "Check Inventory" / تشييك المخزون until
  2026-11-05 -, Order List): sellers were confused when they moved to the side menu only (2026-10-24). Update Stock shows the products sold out,
  Order List `awaitingAnswer()` = pending orders with neither `seller_confirmed_at` nor `seller_unavailable_at`
  (the side menu's Orders count reads the same function). The period picker sits under them.
- **Every amount is the seller's share** (`SellerOrderAmounts::lineSql()`: the line's frozen
  `purchase_price`, else its price on lines from before seller pricing), in dollars with
  `SellerOrderAmounts::usd()`; tax, shipping and the coupon never appear. A product's price is
  `SellerPricing::inputPrice()` (their own price). Never show `unit_price` / `grand_total` here.
- **Sales** = live lines (`void_order_statuses()` excluded on the line and the order) of the seller's
  orders placed in the range, by `orders.date` in PHP buckets (`PlatformMetricsService` helpers; one
  day = hours). **Earned** = the part whose order is `paid` (what `calculateCommission` credits),
  waiting = the rest (decided 2026-10-24: placed + paid split). Compared with the same-length range
  before. Ranges: today / 7 days / 30 days (default) / this month / 3 months / 12 months / custom
  (≤ 3 years, never future).
- **Orders received**: `Seller\DashboardController` calls `SellerOrdersReport::sellerSummary()` for the seller
  and the dashboard's dates and shows its seven figures with the report's own labels and tooltips: Orders
  Received, Confirmed in full, Partly confirmed, Not confirmed, Cancelled for another reason, Seller did not
  answer (the five add up to the orders received), Unavailable products. Nothing is recomputed in
  `SellerDashboard` (an own copy was removed 2026-10-25): a change to the report reaches the dashboard.
- **Response time** = the median of Seller Response Time's card for the seller and the dates
  (`SellerResponseReportController::sellerCardFor()` → its `sellerCard()`; business hours, shown like the report).
  **Stock replenishment** = Seller Inventory Updates' row for the seller (`SellerInventoryActivity::report()`):
  weeks with an update of their own / the period's weeks. Both read from the reports, never recomputed.
- Balance = `shops.admin_to_pay`, withdraw requests with `status = 0`, `payments` (payouts). **Hidden** on the
  page and in the side-menu card while `SellerDashboard::SHOW_BALANCE` is false (balances not switched on yet).
- Products: best sellers and sales per `products.category_id` in the range; stock of the products on
  sale **now** (published + approved, physical, not auction): out (no piece), partial (some variant at
  ≤ 0), low (≤ `SellerDashboard::LOW_STOCK` = 5 pieces), fine; "restock first" orders them by demand.
- Demand **now**, whatever the range: wishlist rows, cart rows touched in the last 30 days on
  products on sale (owner = user or guest id), `product_stock_alerts` waiting, published reviews.
  Product views are not shown: `last_viewed_products` keeps only a customer's last 12.
- Nothing cached (~30 queries, < 100 ms for the busiest local seller over 12 months). Chart: ApexCharts
  3.49.1 from jsdelivr, like the admin analytics page. Web only; the seller app keeps `sales-stat`.

### 4.10c Seller statement (2026-10-29)
`GET /seller/statement` (`seller.statement`, side menu under Orders, "See the statement" on the dashboard's sales
card) → `Seller\StatementController` → `App\Services\SellerStatement` (static; its docblock is the map) →
`seller/statement/index.blade.php` (+ `styles`, `partials/products`, `partials/orders`). `seller.statement.print`
is a standalone A4 page that opens the print dialog; `seller.statement.export/{products|orders}` are two Excel files
(`App\Exports\SellerStatementExport`: shop, period and filters on top, amounts as numbers with the currency in
the column title). All three take the page's query string and cover every matching row. Decisions: ADR 0007.

- **Reused, never copied:** the period is `SellerDashboard::period($request, 'month')` (opens on this month; the
  dashboard keeps 30 days), amounts `SellerOrderAmounts::lineSql()` / `money()`, dead = `void_order_statuses()` on
  the line or the order, paid = `orders.payment_status`, the seller's answer `SellerOrdersReport::answersFor()` /
  `waitingForSeller()` and its filter `orderIdsWithAnswer($state, $sellerId)`, the code / invoice search
  `where_order_code_or_invoice()` (shared with the seller orders list), product names and photos
  `SellerDashboard::productCard()`, paging `per_page(25)`.
- **Filters judge the Child Order:** delivery statuses (shown grouped by stage, `SellerStatement::STAGES`), payment
  status, the seller's answer, one of the seller's products, an order code or invoice number. A line cancelled
  alone still shows inside its order. With a product picked, the order list shows the orders holding it (whole),
  while the product summary and the totals count that product only. **Opens on paid + delivered**
  (`SellerStatement::DEFAULTS`, the user's choice 2026-10-29); the form and every link of the page send `_f=1`
  (`CHOSEN`), so "All" / nothing ticked stays as chosen. The dashboard's link sends `_f=1` too: it opens on the
  card's own orders (every live order), so the two totals match.
- **Totals** = the seller's sales (`SellerDashboard::earnings()` sales for the same orders; pinned by a test), plus
  cancelled / returned (whole orders, every dead line's pieces) and the live pieces per stage. **Paid / due are the
  seller's account, never the customer's payment** (the user, 2026-10-29 / 2026-11-03): due now = `shops.admin_to_pay`
  ("Due to seller" in Admin → Sellers → "Go to Payment", `CommissionController::pay_to_seller`), paid so far = every
  `payments` row but the seller paying us (`SellerDashboard::payoutsQuery()`, never negative), both from `SellerDashboard::balance()`
  (shown here although the dashboard hides its card); "Payments you received" lists the period's payouts
  (`SellerStatement::payouts()`, `partials/payouts`, linked to the seller's payout history). On the page, the print
  and the products Excel (its last block). Payouts carry no order, so nothing is paid / unpaid per order or product.
  The "Go to Payment" window (`backend/sellers/payment_modal`) has **no "Payment Method" field** since 2026-11-03 (the
  money is handed over by hand, by our delivery): it always sends `payment_option = cash` as a hidden field. Never drop
  that field: `pay_to_seller` reads a missing method as the seller paying us and adds the amount to the balance.
  Both pay windows (this one and the payout-request one) have a **note** (`partials/payment_note` →
  `payments.note`, utf8mb4, migration `2026_11_03_000001`; the user, 2026-11-03): shown to the seller next to each
  payout on the statement (page, print, the products Excel's payout rows), on their payout history and in the
  seller app's `payment-history` (`note`, an added key), and to staff on Admin → Sellers → Payouts and a seller's
  payment history.
- **Payout history** (سجل الدفعات, `seller.payments.index`, `App\Services\SellerPayoutHistory`, its docblock is the
  map): every `payments` row of the seller, newest first, with the note; filters = period (all time by default, else
  the statement's chips and custom dates through `SellerDashboard::period()`) and words in the note (no "payment
  kind" filter on screen, the user 2026-11-03; `kind` stays in the service); the dark band totals paid to you and
  "Clear due" apart, in the seller's currency. The note sits before the amount on every screen. The statement's
  "Payout history" link opens it on the statement's period.
- **Product summary:** one row per product + variant + unit price (the line's amount ÷ quantity), sold and
  cancelled-or-returned pieces and the total of the live lines; a subtotal row per product with several rows. On
  screen it is paged by whole products (`SellerStatement::productsPage()`, 20 a page, its own `products_page`
  parameter and plain links, so it never follows the order list's "Rows per page"); the total row under it is always
  the whole statement's; print and Excel list every product. No
  "paid pieces": the customer's payment appears only as the order list's "دفع الزبون" (customer payment) column and
  filter, named so it never reads as the seller being paid.
- No customer name, no payment method, no balance (`SellerDashboard::SHOW_BALANCE`). Web only.

### 4.10e Seller orders list (redesigned 2026-11-05)
`GET /seller/orders` (`seller.orders.index`) → `Seller\OrderController@index` (+ `orderListFilters()` /
`orderListQuery()`) → `seller/orders/index.blade.php` + `partials/list_styles` (the dashboard's and the statement's
colours, prefix `kso-`).

- **Tabs** = `SellerStatement::STAGES` plus "Needs your answer" (`tab=awaiting`, the rule of
  `SellerDashboard::awaitingAnswerQuery()`: pending, not confirmed, not reported unavailable - the same count as the
  dashboard button and the side menu). Each tab shows its count under every other filter.
- **Filters**, applied on change: order date (`orders.date`; all time by default, chips and custom dates through
  `SellerDashboard::period()`), customer payment (`payment_status`), the seller's answer
  (`SellerOrdersReport::orderIdsWithAnswer()`, `SellerStatement::ANSWERS`), one of the seller's products
  (`SellerStatement::productOptions()`), order code / invoice number (`where_order_code_or_invoice()`).
  Old links keep working: `?confirmation=awaiting` opens the tab, `?confirmation=confirmed` and `?delivery_status=`
  still filter (the view keeps a `name="confirmation"` field, pinned by `SellerOrderConfirmationTest`).
- **Columns**: order (code + date placed), products (photos, first name, count, warehouse note), the seller's amount
  (`SellerOrderAmounts::total()`), status (stage + the seller's answer), customer payment, action ("Answer the order"
  on orders waiting for them, else "Open", plus the invoice). Fixed layout, no side scroll: the payment folds under
  the status between 992 and 1199px, and below 992px every order is a card (two per row on tablets). The customer's
  name is no longer listed (it stays on the order page). Excel exports the ticked orders (`orderBulkExport`).

### 4.11 Staff creation & password policy
1. `admin/staffs/create` → `StaffController::create` 42 lists roles except id 1.
2. `store` 54: unique email check only → user `staff` + `Hash::make` (no length/policy check) →
   `staff` row → `assignRole(Role::findOrFail($request->role_id)->name)` 67. `role_id` is not
   validated server-side.
3. Policy: `business_settings` `staff_password_rotation`, `_days` (30), `_history_count` (5),
   `_min_length` (8), `_include_admins`. `User::booted` 42-60 stamps `password_changed_at` and
   pushes the old hash into `password_history` (cap 12) on **every** password write anywhere.
4. Enforcement: `IsAdmin` 35-47 redirects to `admin.password.expired` unless the route is in
   `ALLOWED_WHILE_EXPIRED` 15-20 → `StaffPasswordController::update` 35 (current password,
   min length, `reuseError`: current, history, similarity ≥ 70 % or containment).
   Settings page route `staff_password_policy.index` (admin.php:432).
Tests: `StaffPasswordRotationTest` (17), `StaffPasswordRotationRuntimeTest` (7, opt-in live DB).

### 4.12 Impersonation ("Login as")
- **Account managers (since 2026-11-01).** Sellers → Account managers (`account_managers.*`, `Admin\AccountManagerController`, `manage_account_managers`, rules in `App\Services\AccountManagers`) creates staff whose role is always E-commerce (the role is not a form field; found by name ignoring capitals/spaces/dashes) and can switch them off/on (`users.banned`); it reaches only staff with that role. `SellerController::login` (route `permission:login_as_seller`) also checks `AccountManagers::canEnterSellerAccounts()` - not banned and (holds `login_as_seller`, Super Admin included, or is an E-commerce staff member); the "Log in as this Seller" menu item uses the same check. Migration `2026_11_01_000001_add_account_manager_permission` adds the permission (to "full access") and gives the E-commerce role `view_all_seller`, `view_seller_profile`, `edit_seller`, `login_as_seller`. Texts: `sqlupdates/kapitano_account_managers_2026-11-01.sql` + the label in `translations.json`. Tests: `tests/Feature/AccountManagersTest.php`. **A seller's account manager** is `shops.account_manager_id` (a user id, no foreign key; migration `2026_11_01_000002`, SQL `sqlupdates/kapitano_seller_account_manager_2026-11-01.sql`): an "Account manager" dropdown on Add/Edit Seller, shown only to staff holding `assign_seller_account_manager` (`AccountManagers::canAssign()`; since 2026-11-05, migration `2026_11_05_000002`, granted to Product Manager, IT and Super Admin - `add_seller` / `edit_seller` alone no longer show it, and a value they post is ignored, neither saved nor refused) (`AccountManagers::assignable()`: active managers, plus the seller's current one even if switched off; anyone not in the E-commerce role is refused), a column and a filter on the Sellers list (`Shop::ofAccountManager`). It is a reference only - every account manager can still open every seller; a manager who leaves the role simply reads as "not assigned". The screens leave it out until the column exists (`assignmentAvailable()`).
- `GET /admin/customers/login/{id}` → `CustomerController::login` 314 (`permission:login_as_customer`)
  and `GET /admin/sellers/login/{id}` → `SellerController::login` 427 (`login_as_seller`; id is a
  shop id). Both `decrypt($id)` → `AuthGuards::login($user, true)` → `start_impersonation($admin, $user)`
  (Helpers 5014) stores `impersonator_id/type` + `impersonated_user_id` in session.
- `impersonator_user()` 5040 honours the marker only while `impersonated_user_id == auth()->id()`.
  Consumers: `ActingUserResolver` (audit `user`), `ImpersonationTagResolver` (audit tag
  `impersonating:{id}`), `acting_user()` 5081, `log_exchange_rate_change`, `archive_deleted_order`
  5187, order cancellation attribution (`OrderController` ~826).
- One account per browser (ADR 0010): entering the account replaces the admin's session, as it always did; the admin signs in again afterwards.
- Neither `login` checks the target's `user_type` (§8).

### 4.13 Logout / account deletion
- `GET /logout` → `LoginController::logout` (deletes the seller's web-push token from the
  session copy) → `AuthGuards::logout()`: signs the account out of every guard and invalidates the
  session (one account per browser, ADR 0010). Also `Auth::routes` `POST /logout`.
- `GET /account-deletion` → `account_deletion` 361 — hard-deletes the user and their upload files.
- API `GET /api/v2/auth/account-deletion` → `AuthController::account_deletion` 904.

### 4.10f Seller working hours (2026-11-07)

Map: the docblock of `App\Services\SellerWorkingHours`. Term in `CONTEXT.md` ("Working hours").

- **Tables** (migration `2026_11_07_000002`, new tables only): `shop_working_hours` (one row per shop with its own week,
  `schedule` JSON `{"sat":[{"from":"10:00","to":"14:00"},...],"fri":[]}`, `updated_by`) and `shop_closures`
  (`starts_on` / `ends_on` inclusive, `reason`). No row = the general hours (`seller_whatsapp_business_start` / `_end`).
- **Rules**: Syria's time (`SellerWorkingHours::TIMEZONE`, never `app.timezone`); at most two periods a day, not overlapping,
  `to <= from` runs past midnight, an end of 23:59 is stored as 24:00 (midnight) so a period ending at midnight joins the next
  day's 00:00 period; ended closures are not loaded. `normalize()` is the only check, used by every writer.
- **Screens**: Admin → Sellers → "Seller working hours" (`seller_working_hours.*`, `Admin\SellerWorkingHoursController`: tabs
  open / closed / on holiday / no own hours, account manager filter, edit page; `view_all_seller` looks,
  `manage_seller_working_hours` edits; tick sellers - this page or the whole filtered list, kept across pages in
  sessionStorage - and "Set hours for the selected" posts one week to `seller_working_hours.bulk` → `saveMany()`, which
  replaces their own week and leaves their closures); seller panel → "Working hours" (`seller.working_hours*`); seller app
  `GET|POST v2/seller/working-hours`, `POST v2/seller/working-hours/closures`, `DELETE …/closures/{id}`
  (contract `docs/api/seller-working-hours.md`). Both forms are one partial (`backend/sellers/working_hours/partials/week_form`
  + `closures`).
- **Orders**: the admin seller order page and each seller of the combined order page show the state now, today's hours and
  "the order arrived while the seller was closed" (`orders.date`), in red while the seller has not answered
  (`partials/order_note`); the orders list shows a short badge and filters `seller_hours=open|closed`
  (`SellerWorkingHours::sellerIdsNow()`). The sellers of a page are loaded once (`forSellers()`, three queries).
- **Not using them yet**: the WhatsApp alert timing and the Seller Response Time report still use the general window.

### 4.10g Seller status and warnings (2026-11-09)

Map: the docblock of `App\Services\SellerStatus`. Decided with the user 2026-11-09.

- **Five statuses**, one per seller: pending (قيد الانتظار), active (نشط), rejected (مرفوض), suspended (موقوف مؤقتاً),
  banned (محظور). **Nothing new on `shops` / `users`**: the status is read from `users.banned` (banned),
  `shops.verification_status` (1 = active) and, for the rest, the newest `shop_status_changes` row (rejected / suspended,
  else pending). So "Seller on sale" (§4.10d) and every customer path are unchanged: only active sells.
- **One writer**: `SellerStatus::change()` / `changeMany()` writes those two columns + one `shop_status_changes` row
  (from, to, reason, note, `until`, who). A reason (`SellerStatus::REASONS`) is required for rejected / suspended / banned,
  a note for "other". Banning or unbanning needs `ban_seller`, every other move `approve_seller` (`canChange()`).
  Rejecting clears `verification_info` (so the seller can send the form again: `Shop::updated` → `shopUpdated()` then
  logs rejected → pending). A `verification_status` written anywhere else is logged with no reason. A suspension may end on
  a date: `sellers:end-suspensions` (hourly) makes the seller active again.
- **Warnings** (`shop_warnings`, permission `warn_seller`): internal, they hide nothing and the seller never sees them;
  they stay until removed by hand.
- **Admin**: the Sellers list lost its Approval switch; the Status column shows the status, its reason, the end of a
  suspension and the open warnings (`backend/sellers/status/partials/badge`); one filter "Filter by status" (+ "Has a
  warning") replaced the verification and approval filters (old `?approved_status=` / `?verification_status=` links still
  work); ⋮ → Change status / Give a warning / Status history; bulk action "Change status". The verification page's Reject
  opens the same window (`sellers.reject` redirects there with `?reject=1`). The old routes `sellers.approve`,
  `sellers.approved` (switch) and `sellers.ban` (now unban only) go through `change()`. Windows:
  `backend/sellers/status/partials/modals` (in `@section('modal')`) + `partials/script` (in `@section('script')`).
- **The seller** reads the reason and note while rejected or suspended: a red banner on every panel page
  (`seller/partials/status_banner`), the side menu badge, a notification (rejected: `shop_verify_request_rejected`;
  suspended: `shop_suspended_seller`; back to active: `shop_reactivated_seller` or `shop_verify_request_approved`), whose
  text gets the reason through `SellerStatus::notificationText()` (bell components, `Api/V2 NotificationCollection`,
  `NotificationText`). Seller app: `seller_status*` keys on `GET v2/seller/shop/info` (`docs/api/seller-status.md`).
  A banned seller cannot sign in, so they see none of it.
- **Seller profile = the seller at a glance** (2026-11-10): Sellers list → ⋮ → Profile (`sellers.profile_modal`,
  `view_seller_profile`; the seller's name opens nothing). `App\Services\SellerDetails` (its docblock is the map):
  `build()` = who (status + reason, warnings, entry type, account manager, working hours now, currency, WhatsApp / log in
  as / edit) and the figures: sales = live lines price + tax in SYP at each order's rate, orders / delivered /
  cancelled-or-returned rate, our booked commission (`commission_histories` at the order's rate), due to seller and paid so far
  (`SellerDashboard::balance()`), the seller's answers (`SellerOrdersReport::sellerSummary()`, all time) and median response
  (Seller Response Time's card, 90 days), products on sale / sold out / awaiting approval / new, last stock update,
  12 months, sales by category, the newest status history. `list()` = one page of the three full lists: products per main
  category (all / on sale / sold out, 10 a page), products sold (10), every order (15), loaded in place from
  `sellers.profile_list/{id}/{categories|products|orders}?page=`. Views `backend/sellers/profile_modal` (hero, about,
  branches, payout) + `details/sections` + `details/list`. The profile's old four numbers are gone ("Total Orders" counted
  order lines, the sold amount used today's rate). About 55 queries, 1.4 s on the busiest seller of the local copy; a list
  page 6 queries.
- Pinned by `tests/Feature/SellerStatusTest.php`. Deploy: `php artisan migrate` (`2026_11_09_000001`: two new tables,
  the permission, two notification types, the Arabic strings), then `php artisan cache:clear`. History starts at deploy.

---

## 5. Middleware & guards reference

**Three guards since 2026-11-09** (ADR 0010, map in `App\Services\Auth\AuthGuards`' docblock):
`web` (storefront), `seller` (seller panel, `users.user_type = seller`), `admin` (admin panel, admin +
staff), all on `users` (`App\Auth\RoleEloquentUserProvider` limits a guard to its roles; `web` reads
customers and delivery boys only). `UseRequestGuard` (web group, right after StartSession, also in
`$middlewarePriority`) makes the request's guard the default: a route with the `admin` / `seller`
middleware or under `/admin` / `/seller` uses that guard, a shared route (uploader, notes) the panel
that is signed in, else `web` - so every `auth()->user()`, `auth`, `IsAdmin`, `IsSeller`, `@auth`
reads it. **One account per browser**: sign in only with `AuthGuards::login()`, which quietly signs
out whoever was signed in before (no Logout event, their remember token untouched); sign out with
`AuthGuards::logout()` (ends the session) / `logoutUser()`. Sessions and remember-me cookies from before that hold a seller /
admin in `web` are moved into their guard once (`adoptWebSession()`). Spatie roles / permissions stay
on the `web` guard name (`User::$guard_name`, explicit `guard_name` in `RoleController`).

**One account per role group** (customer, seller, admin/staff, delivery boy; `App\Services\Auth\AccountRoles`):
the same phone and email may hold one account in each. Phone / email rules use
`AccountRoles::unique()`; sign-in, registration and reset lookups `AccountRoles::scope()`; the forgot
password forms, which do not say which account, `pickForPasswordReset()` (admin, staff, customer,
seller, delivery boy). The email index is `users_email_user_type_unique` (migration
`2026_11_09_000001`). Tests: `actingAs()` fills the user's own guard (`Tests\TestCase::be()`). Pinned
by `tests/Feature/AuthGuardsTest.php` and `AccountsPerRoleTest.php`.

Global (`Kernel::$middleware`): ValidatePostSize, TrimStrings, ConvertEmptyStringsToNull,
TrustProxies, **StartSession**, ShareErrorsFromSession — so API requests also start sessions.

| Group / alias | Class | Behaviour |
|---|---|---|
| `web` | AddQueuedCookies, VerifyCsrfToken, SubstituteBindings, Language, HttpsProtocol, CheckForMaintenanceMode | **EncryptCookies and AuthenticateSession are commented out** (Kernel 43-46). |
| `api` | Sanctum EnsureFrontendRequestsAreStateful, `throttle:api` (600/min), SubstituteBindings, CheckForMaintenanceMode, `EnsureSystemKey` | Missing/wrong `System-Key` → HTTP 200 `{result:false,"Request not found!"}`. Bypassed for `api/v2/order/cancel2/*`, `api/v2/paymera/*`, `api/v2/product/add/ai` and the `withoutMiddleware([EnsureSystemKey])` block in api.php:638. |
| `auth` | `Authenticate` | web session guard; `/admin`, `/seller`, delivery-boy and `/api` paths redirect to `route('login')`, every storefront path to `user.login` after remembering the page (§4.1a). `auth:sanctum` for API (Sanctum guard list = `web`, so a stateful-domain session also authenticates). |
| `admin` | `IsAdmin` | admin **or staff** else 404; password-rotation redirect. Reads the `admin` guard: a customer / seller is not signed in there, so `auth` sends them to the login page (401 JSON) first. |
| `seller` | `IsSeller` | reads the `seller` guard: guest (a customer included) → 401 JSON / `seller.login` (stores `session('link')`); banned → `AuthGuards::logoutUser()`. |
| `customer` | `IsCustomer` | customer only, else `user.login` (remembers the page, §4.1a). No ban check (routes add `unbanned`). |
| `user` | `IsUser` | customer, seller or delivery_boy. |
| `unbanned` | `IsUnbanned` | logs banned users out. |
| `app_user_unbanned` | `IsAppUserUnbanned` | deletes current token of banned API user; assumes authenticated (`$user->banned` on null would fatal **(unverified whether any route in the group lacks `auth:sanctum`)**). |
| `checkout` | `CheckoutMiddleware` | forces login unless `guest_checkout_active` = 1 (reads DB each request). |
| `permission` / `role` / `role_or_permission` | Spatie | `permission:x|y` = any of. |
| `guest` | `RedirectIfAuthenticated` | used by stock Auth controllers. |
| `handle-demo-login` | `HandleDemoLogin` | no-op. |
| `prevent_db_action` | blocks writes when `DEMO_MODE=On`. |
| `throttle:N,1` | only on search tracking, seller WhatsApp action links (`/so/*`, 30/min), campaign clicks. **No auth/OTP route has a throttle** except `/login` (trait) and `WhatsAppOtpController::send` (manual RateLimiter). |

Route groups:
- `routes/web.php`: OTP login/registration routes (361-378, 314-331) have **no auth or throttle**;
  `['user','verified','unbanned']` 526 (dashboard/profile/profile update);
  `['customer','verified','unbanned']` 562; `['auth']` 630 (addresses, reviews, conversations).
- `routes/admin.php`: `['auth','admin','prevent-back-history']` prefix `admin` 167 plus per-route /
  per-controller `permission:` middleware; audit log gated in-controller to `user_type=admin` only.
- `routes/seller.php`: public PWA 10-19; `['seller','user']` push token 22; `['seller','verified','user','prevent-back-history']` 31 and 41 (whole panel). No `unbanned` (IsSeller covers it).
- `routes/api.php`: `v2/auth/*` public except logout/user/account-deletion/resend_code/confirm_code
  (`auth:sanctum`); most customer routes add `auth:sanctum` per route inside the
  `app_user_unbanned` group 307.
- `routes/api_seller.php`: `auth:sanctum` only — **no check that the token belongs to a seller**
  and no `app_user_unbanned`.
- `routes/seller_package.php`, `routes/delivery_boy.php`, `routes/otp.php`: reference controllers
  that do not exist (`SellerPackageController`, `DeliveryBoyController`, `OTPVerificationController`,
  `OTPController`, `SmsController`, `SmsTemplateController`) → 500 if hit.

---

## 6. "Change together" list (most important)

When you touch one item, grep and update every sibling listed.

1. **TempAuth OTP issue/verify** — `HomeController::sendOtpForLogin` 713, `sendOtpForSellerLogin` 764,
   `sendOtpForLogin2` 937, `verifyOtpAndLogin` 1133, `verifyOtpAndLoginSeller` 842;
   `Api/V2/AuthController::sendOtp` 346, `verifyOtp` 423; `CheckoutController::checkPhone` 121,
   `verifyPhone` 204. Any expiry, attempt limit, CSPRNG or hashing change must land in all 9, and in
   the TempAuth schema. (`OtpService` already has expiry+hashing but nothing uses it for login.)
2. **`sendCodeForWhatsApp`** — duplicated in `HomeController` 1204 and `Api/V2/AuthController` 401;
   direct `OtpSenderService::sendOtp` calls also in `HomeController::sendRegVerificationCode` 2245,
   `ShopController` 231, `RegisterController::create` 100, `ForgotPasswordController` 93,
   `Api/V2/AuthController::signup` 149 / `resendCode` 526, `Api/V2/PasswordResetController` 37/104,
   `CheckoutController::checkPhone` 161, `OtpService::send` 62.
3. **Phone normalisation** — `normalize_otp_phone` (Helpers 5292), `compose_e164_phone` 5331,
   `CheckoutController::normalize_checkout_phone` 46 (own copy), `SellerProfileRequest`,
   `SellerRegistrationRequest`; plus raw concatenations `"+{$cc}{$phone}"` in
   `LoginController::credentials` 257, `cart_login` 1437, `RegisterController` 94/147,
   `ForgotPasswordController` 59, `HomeController::sendRegVerificationCode` 2218; digit-only
   stripping in `WhatsAppOtpController` 38, `OtpService` 44, `BuzzBipService::sendOtp` 114,
   `IshaarService::normalizePhone` 144. Un-normalised writers: `userProfileUpdate` 1518,
   `CheckoutController::createUser` 660, `CustomerController::store` 171, `StaffController`
   60/116, `ShopController::store` via request class (normalised). Tests: `OtpPhoneSpacesTest`,
   `ComposeE164PhoneTest`.
4. **User creation** — `HomeController::register` 1265, `registerSeller` 993,
   `RegisterController::create` 80, `Api/V2/AuthController::signup` 39, `register` 174,
   `guestUserAccountCreate` 745, `socialLogin` 717, `LoginController::handleProviderCallback` 171,
   `handleAppleCallback` 86, `CheckoutController::createUser` 657, `ShopController::store` 104,
   `SellerController::store` 149, `CustomerController::store` 171, `StaffController::store` 57.
   Referral/loyalty grant block copied 4× (HomeController 1271, RegisterController 156, API signup
   78, API register 181); referral-code generator copied 5× (`Str::upper(Str::slug(...))` + random).
   `offerUserWelcomeCoupon` called with and without `$user`.
5. **Guest-cart merge on login** (`temp_user_id`) — HomeController 1172, 1417;
   `LoginController::authenticated` 269 (deletes cart for non-customers), `handleProviderCallback`
   191, `handleAppleCallback` 106; `RegisterController::create` 110; `CheckoutController::verifyPhone`
   292, `createUser` 703; API `loginSuccess` 819, `verifyOtp` 482, `register` 320,
   `guestUserAccountCreate` 799. Semantics differ (some delete the user's existing cart first).
6. **Seller + shop creation / slug** — `verifyOtpAndLoginSeller` 893 and `registerSeller` 1037 use
   `uniqueShopSlug` 825; `ShopController::store` 117 uses a raw slug; `SellerController::store` 161
   uses `demo-shop-{id}` (161); API `register` with `user_type=seller` creates no shop. Tests:
   `SellerOtpLoginShopTest`, `SellerWorkflowRuntimeTest`.
7. **Post-login redirect by type** — `LoginController::authenticated` 267, `verifyOtpAndLogin` 1188,
   `verifyOtpAndLoginSeller` 908, social callbacks 117/204, `VerificationController` 97,
   `reset_password_with_code` 2111, `email_change_callback` 2088, `HomeController::dashboard` 1472 /
   `profile` 1489, `seller_post_login_redirect` 5599, `customer_post_login_redirect` (+ the guards and popups of §4.1a). Tests: `SellerLoginRedirectTest`, `LoginReturnToPageTest`,
   `SellerAuthRedirectTest`.
8. **Profile / phone / password update** — `HomeController::userProfileUpdate` 1504,
   `Seller/ProfileController::update` 31 (+`SellerProfileRequest`), `Api/V2/ProfileController::update`
   52, admin `SellerController::update` 226, `CustomerController::update` 281, `StaffController::update`
   110. Only the seller and API versions validate phone shape/uniqueness; none verifies ownership.
9. **Ban checks** — `IsUnbanned`, `IsSeller` 44, `IsAppUserUnbanned`, inline in
   `verifyOtpAndLogin` 1167, `verifyOtpAndLoginSeller` 873, API `login` 624 / `verifyOtp` 475,
   `can_login_at_checkout` 71. `cart_login`, social logins, `reset_password_with_code`,
   `verification_confirmation`, `email_change_callback` and API `socialLogin` don't check.
10. **User-type checks** — middlewares (`IsAdmin`, `IsSeller`, `IsCustomer`, `IsUser`) vs helpers
    `isAdmin/isSeller/isCustomer` (Helpers 2981-3009) vs inline `user_type ==` comparisons
    (hundreds). "staff counts as admin" appears in `IsAdmin`, `isAdmin()`, `IsUnbanned`,
    `LoginController`, `start_impersonation`.
11. **Seller withdraw requests** — `Seller/SellerWithdrawRequestController::store` 33 and `Api/V2/Seller/WithdrawRequestController::store` 30
    (`user_id` = user id, both checked by `SellerWithdrawRequest::refusal()`: minimum ≤ amount ≤
    `admin_to_pay` − pending requests), admin `SellerWithdrawRequestController::store` 49 (**`user_id` =
    shop id**). Payout: `CommissionController::pay_to_seller` 25 / `seller_payment_done` 70.
12. **Impersonation attribution** — `CustomerController::login` 314, `SellerController::login` 427,
    `start_impersonation`, `impersonator_user`, `acting_user*`, `ActingUserResolver`,
    `ImpersonationTagResolver`, `log_exchange_rate_change`, `archive_deleted_order`. Tests:
    `ModelAuditingTest`, `AuditLogPageRuntimeTest`.
13. **Password reset / verification_code** — `ForgotPasswordController` 49,
    `HomeController::reset_password_with_code` 2099, `VerificationController::verification_confirmation`
    84, API `PasswordResetController` 17/57/81, API `signup`/`resendCode`/`confirmCode`,
    `RegisterController::create` 96. One column serves three purposes.
14. **Account deletion** — `LoginController::account_deletion` 361, API `account_deletion` 904.
15. **Address writes** — web `AddressController` 39/97/125/160, API `AddressController` 23/44/65/79/95,
    `CheckoutController::createUser` 690, `guestUserAccountCreate` 786, `clean_address_notes`.
16. **Password-rotation hook** — any new password writer is automatically covered by
    `User::booted`, but a new admin-panel route file must still use the `admin` middleware.
17. **How staff screens name a seller** — by the **shop name** (`seller_display_name($user)` in
    `Helpers.php`: `shops.name`, else `users.name`; eager-load `user.shop` on lists). Used by the
    dashboard's Top Sellers, Products Added (seller, added-by, vendor filter), the product /
    digital / reviews lists, commission log filter, seller payments / withdraw requests, the
    seller profile popup and the sales-profit report. Orders and the sellers list already read
    `shops.name`. The owner's own name stays only where the person is meant (seller edit form,
    verification page, ordering page's owner column).

---

## 7. Permissions model summary

- Package: `spatie/laravel-permission` (`HasRoles` on `User`, guard `web`). Staff get exactly one
  role (`StaffController` `assignRole`/`syncRoles`); `staff.role_id` duplicates it.
- `Gate::before` (AuthServiceProvider 29-31): role **Super Admin** passes every `can()` and
  `permission:` check. Whether an `admin` user_type without that role passes depends on the role
  assignment in production **(unverified)** — `user_type` itself grants nothing to Spatie.
- Check styles in use (all equivalent, pick the local one):
  - Controller constructors: `$this->middleware(['permission:x'])->only('method')` — the dominant
    pattern (`CustomerController` 15-29, `SellerController` 22-45, `StaffController` 13-24,
    `RoleController` 13-25). Comments "write actions and AJAX endpoints reachable by URL" mark
    Kapitano's hardening pass that added `store/update` coverage.
  - Route-level `->middleware('permission:x')` and group `['permission:x']` in `admin.php`.
  - Inline `auth()->user()->can('x')` (e.g. `SellerController::updateHiddenProductFields` 416).
  - Blade `@can('x')` (~355 occurrences) to hide menu items/buttons.
  - `user_type` checks for things outside Spatie (audit log = admin only; accountant helper uses
    `hasPermissionTo` deliberately, User 286-298).
- `IsAdmin` admits every staff account to every `/admin` URL; a route without a permission
  middleware is therefore open to all staff **(unverified how many remain — grep `admin.php` for
  routes whose controller has no constructor permission map)**.
- Adding a permission: follow the migrations `2026_09_*_add_*_permission*.php` pattern (insert into
  `permissions` with `section`, add `translations` for `ar`/`sa`, optionally grant to roles), then
  `php artisan cache:clear` (Spatie cache). Keep `docs/permissions-reference.md` in sync
  (326 permissions / 42 sections as of this commit).
- Custom Kapitano permissions (from migrations): `coupon_usage_report`, `clone_or_move_product`,
  `add_app_popups`, `edit_app_popups`, `view_all_app_popups`, `cart_retargeting`,
  `view_order_follow_up`, `check_order_follow_up_items`, `decide_order_ready_to_ship`,
  `manage_delivery_agents`, `mark_order_paid`, `view_order_delivery`, `update_order_delivery`,
  `investor_report`, `kapitano_growth_report`, `view_kapitano_growth`,
  `manage_best_selling_products`, `seller_response_report`, `seller_whatsapp_settings`,
  `act_as_accountant` (+ accountant role), `update_settings`, `notification_settings`,
  report permissions (`aov_report`, `cart_activity_report`, `cart_report`,
  `customer_demographics_report`, `least_sold_products_report`, `most_sale_items_report`,
  `most_sold_products_report`, `order_time_report`, `product_category_distribution_report`,
  `products_added_report`, `products_report`, `repeat_purchase_report`, `sales_by_city_report`,
  `sales_profit_report`, `top_categories_report`, `top_customers_report`, `dashboard_reports`),
  `edit_customer`, `stock_zeroing_report`, `manage_home_section_products`,
  `manage_whatsapp_campaigns`, `manage_partner_stores`, `bulk_change_product_categories`,
  `view_fake_discounts`, `add_fake_discount`, `edit_fake_discount`, `activate_fake_discount`,
  `delete_fake_discount` (replaced `manage_discounts`), `generate_compensation_coupon`,
  `delete_product_review`, `edit_order`, `manage_uploaded_files`, `sitemap_generator`,
  `view_register_invitations`. Domain-relevant stock ones: `login_as_customer`, `login_as_seller`,
  `add/edit/delete_staff`, `view_all_staffs`, `add/edit/delete_staff_role`, `view_staff_roles`,
  `approve_seller`, `pay_to_seller`, `set_seller_based_commission`, `view_seller_payout_requests`,
  `otp_configurations`, `social_media_logins`.

---

## 8. Security issues & gotchas

Severity is my estimate. "Critical" = likely account takeover. None was exploited or tested live.

**Critical**
1. **Unbounded 4-digit OTP brute force, no expiry.** `temp_auth` has no TTL or attempt counter;
   a wrong guess does not invalidate the row (`HomeController` 1159-1161 / 865-867, API 448,
   `CheckoutController` 240). Web verify routes have no throttle; API is 600/min/IP. OTPs come from
   `rand()` (733, 781, 958, API 365). 10 000 guesses cover the space.
2. **Web OTP logs in any user_type, including admin/staff.** `verifyOtpAndLogin` 1163 and
   `verifyOtpAndLoginSeller` 869 match `User::where('phone')` with no type filter → combined with
   item 1, any admin/staff account with a phone on record can be taken over without its password.
   (Checkout deliberately restricts to customers — `can_login_at_checkout` 71 — the login forms do not.)
3. **`reset_password_with_code` null-code bypass** (HomeController 2101): if `code` is omitted,
   `ConvertEmptyStringsToNull` + `where('verification_code', null)` becomes `whereNull`, matching any
   account with no pending code → password set + login. Same pattern in `email_change_callback`
   2077 (`?new_email_verificiation_code=&email=…` → first user with a null code, likely id 1, is
   logged in and its email replaced). **High confidence from Laravel's `where(col, null)` semantics;
   verify on a staging copy before acting.**
4. **Code-only lookups**: `VerificationController::verification_confirmation` 85 (GET, logs in whoever
   holds that 4-digit code) and API `confirmReset` 63 (resets the password of whoever holds the code).
   `forgetRequest` lets an attacker put a code on any account first.
5. **API social login takeover**: `socialLogin` 704 looks up `provider_id` = client-supplied
   `$request->provider`, not `$social_user_details->id`; any valid token for the attacker's own
   social account plus a victim's provider id logs in as the victim.
6. **Deterministic password for admin-created sellers**: `SellerController::store` 147 hashes a
   constant string, so every seller created from the admin panel shares one guessable password usable
   on `/login`, `/users/login/cart` and API `login` with `user_type=seller`.

**High**
7. **Registration without OTP proof**: `HomeController::register` 1265, `registerSeller` 993, API
   `register` 174, `guestUserAccountCreate` 745, `CheckoutController::createUser` 634 accept any phone
   directly; the OTP step is only a UI convention. Allows squatting other people's numbers.
8. **Self-assigned `user_type`** in API `register` 245/275 (`seller`, `delivery_boy`).
   Delivery-boy endpoints take `{id}` params (api.php 245-259) **(unverified whether they check
   ownership)**.
9. **Unverified phone change**: web `userProfileUpdate` 1518 (no format, no uniqueness), seller and
   API versions (format + unique, no OTP). Because login is by phone, changing it re-points the
   credential. Duplicate phones make `->first()` pick the lowest id.
10. **Staff privilege escalation**: `StaffController::store/update` 66/121 accept any `role_id`,
    including 1 (Super Admin), for anyone holding `add_staff`/`edit_staff`. `RoleController::update`
    can edit role 1 and `add_permission` 140 creates arbitrary permissions with `add_staff_role`.
    No password policy on staff creation (min length only enforced on rotation).
11. **Address IDOR**: web `edit` 81, `update` 99, `destroy` 127, `set_default` 166, `store` with
    `customer_id` 42; API `updateShippingAddress` 46, `updateShippingAddressLocation` 67,
    `makeShippingAddressDefault` 99 — no `user_id` check. API `updateAddressInCart` 110 trusts a
    `user_id` param.
12. **Seller API has no seller check** (`routes/api_seller.php` only `auth:sanctum`).
13. **Public message-sending endpoints**: `POST /otp/whatsapp/send` and `GET /test/whatsapp-otp`
    (web.php 126-144) send attacker-chosen codes from the store's WhatsApp sender (phishing, cost).
    `POST /auth/whatsapp/send-otp` is rate-limited per phone only.
14. **Impersonation has no target-type check**: `CustomerController::login` 317 / `SellerController::login`
    430 log in whatever user the encrypted id resolves to (mitigated by `encrypt()` needing APP_KEY).

**Medium / gotchas**
15. `GET /account-deletion` (web.php:275) hard-deletes the signed-in account on a GET → CSRF-able;
    also `GET /logout`.
16. `EncryptCookies` and `AuthenticateSession` disabled (Kernel 43-46): password changes don't end
    other sessions; cookies are not encrypted. Session lifetime default 43 200 min; Sanctum tokens
    never expire; every API login mints a new token.
17. `EnsureSystemKey` answers 200 not 401, uses `!==` (not constant-time), and the key ships in the
    mobile app, so it is not a secret boundary.
18. `routes/otp.php`, `seller_package.php`, `delivery_boy.php` point at missing controllers;
    `RegisterController::registered` 266 redirects to `route('verification')` (missing controller).
19. `RegisterController::create` 90-108: phone registration without the `otp_system` addon leaves
    `$user` undefined.
20. `dd($e)` in production paths: `HomeController` 1077, 1391; `RegisterController` 226.
21. Admin `SellerWithdrawRequestController::store` 52 stores a shop id in `user_id`.
22. `ShopController::store` 117 slug collision (no `uniqueShopSlug`).
23. `BuzzBipService::sendOtp` logs full phone numbers and response bodies (Ishaar masks them).
24. `IsAdmin` returns 404 for guests (by design, hides the panel).
25. Route name `password.update` is defined twice (`Auth::routes` and web.php:308); `route()` resolves
    to the later one.

---

## 9. Tests covering this domain

All in `tests/`, SQLite in-memory unless noted "runtime" (opt-in `KAPITANO_RUNTIME_DB_TESTS=1`,
live MySQL, rolled back).

| Test | Cases | Covers |
|---|---|---|
| `Feature/SellerOnSaleTest` | 12 | seller on sale: the rule, the ban / approve hooks, cart rows hidden and back, product page 404, app details / variant price / add to cart / shop / seller / flash deal / coupon lists, home category rows, approving a banned seller refused |
| `Feature/SellerOnSaleRuntimeTest` (runtime) | 4 | real admin ban / un-approve, then every web and app customer path; refused approval; back after unban + approve |
| `Feature/CustomerListFiltersExcelTest` | 13 | customers list filters + chips, search by id / phone shape, Excel import preview / apply / changed-since-check / fill-empty-only / errors / untouched old data, export read back unchanged |
| `Feature/OtpCodeLookupTest` | 8 | OTP code lookup: both permissions, the log one never reaches a code, code shown at once, log row without the code, no account / no code, expiry, other code tables, per-employee cap |
| `Feature/OtpPhoneSpacesTest` | 7 | normaliser; web send/verify with spaced numbers; wrong code; API verify |
| `Feature/CheckoutOtpVerificationTest` | 2 | checkout verifies the request phone; stores the exact code sent |
| `Feature/ComposeE164PhoneTest` | 2 | `compose_e164_phone` |
| `Feature/SellerOtpLoginShopTest` | 7 | no duplicate shops, a customer is never promoted (registration instead), a seller signs in beside its customer account, admin not demoted, slug uniqueness, wrong OTP |
| `Feature/SellerLoginRedirectTest` | 5 | `session('link')` handling, `seller_link_is_local` |
| `Feature/SellerDashboardTest` | 8 | seller dashboard: purchase prices only, dead lines / void orders / other sellers out, ranges, buckets, balance, stock states, demand |
| `Feature/SellerDashboardRuntimeTest` (runtime) | 2 | every range renders for the busiest seller; headline = their lines at purchase price |
| `Feature/SellerStatementTest` | 9 | seller statement: totals = the dashboard's, grouping by product + variant + price, filters judge the order, the seller's answer, Excel rows / files, print view, no customer |
| `Feature/SellerAuthRedirectTest` | 6 | `IsSeller` behaviour (guest/JSON/banned/non-seller) |
| `Feature/SellerWorkflowRuntimeTest` (runtime) | 29 | admin creates seller, phone repair, registration, profile normalisation, inventory, product form |
| `Feature/SellerPwaTest`, `SellerPushTest`, `SellerPushNotificationTest`, `SellerPwaTranslationsTest` | 10+ | PWA public endpoints, push tokens |
| `Feature/StaffPasswordRotationTest` | 17 | policy maths, history, reuse, similarity |
| `Feature/StaffPasswordRotationRuntimeTest` (runtime) | 7 | `IsAdmin` redirect, change form, settings page |
| `Feature/ModelAuditingTest` | 6 | secrets excluded, impersonation attribution, role permission sync audit |
| `Feature/AuditLogPageRuntimeTest` (runtime) | 8 | audit page, admin-only gate, filters |
| `Feature/AddressNotesTest`, `DuplicateDeviceTokenCleanupTest` | — | address notes; device-token dedupe |
| `Unit/OtpServiceTest` | 2 | `OtpService` send/verify once; BuzzBip country inference |
| `Unit/OtpSenderServiceTest` | 3 | provider switch, Ishaar refusal → exception |
| `Unit/BuzzBipTemplateSendTest` | — | template send |

**Not covered**: API `register`/`signup`/`login`/`socialLogin`/`guestUserAccountCreate`, password
reset (web + API), `verification_confirmation`, `email_change_callback`, OTP brute force/expiry,
`StaffController`/`RoleController`, address ownership, impersonation entry points themselves,
`EnsureSystemKey`, `api_seller` authorization, withdraw requests.

---

## 10. Open questions

1. Exact production schema of `temp_auth` and whether `users.phone` has a unique index.
2. Does the production admin account (`user_type=admin`) hold the Super Admin role, and do any staff
   or admin rows have a `phone` set (determines real exposure of §8 item 2)?
3. How many `users` rows have `verification_code` / `new_email_verificiation_code` NULL vs set (§8
   items 3-4)? Which rows came from admin "Add seller" (§8 item 6)?
4. Is `business_settings.otp_provider` currently `buzzbip` or `ishaar`?
5. Are `/auth/whatsapp/*` (`WhatsAppOtpController`) and `/otp/whatsapp/send` used by any mobile app
   build? No web view references them.
6. Does the mobile app still call `auth/signup`, `auth/login` (password) and `social-login`, or only
   `send-otp`/`verify-otp`/`register`?
7. Which delivery-boy and seller API endpoints scope by the token's user vs a path `{id}`?
8. Is `sellers` (legacy table) still read anywhere that matters (seller packages)?
9. Are the stock addons (`otp_system`, `seller_subscription`, `delivery_boy`) marked active in the
   `addons` table despite their controllers being absent?
10. Should `OtpService` (hashed, expiring, rate-limited) replace the TempAuth flows? That would be the
    natural single place to fix §6 items 1-2 and §8 item 1.
