# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Critical context — read first

- **This is a live production site** (kapitano.shop — a Syrian e-commerce marketplace, Arabic-first, prices in Syrian Pounds/SYP). Never run destructive artisan commands (`migrate:fresh`, `db:wipe`, seeding) or anything that mutates production data.
- **Origin: CodeCanyon (Active eCommerce CMS).** The codebase has many pre-existing bugs and almost **no reusable code — the same logic is copy-pasted in multiple places**. A fix in one file usually does NOT fix the whole project. Before declaring any fix complete, grep for sibling copies of the same logic across:
  - `app/Http/Controllers/` (web/customer)
  - `app/Http/Controllers/Admin/`
  - `app/Http/Controllers/Api/V2/` (mobile apps)
  - `app/Http/Helpers.php`
  - Blade views under `resources/views/frontend/`, `backend/`, `seller/`
  - Example: a pricing fix must be checked in `Helpers.php`, `CartController`, `Api/V2/CartController`, `CheckoutController`, and the checkout/cart views.
- `kapitano.json` contains Firebase credentials — never paste its contents into code, commits, or chat output.
- The `create-laravel-feature` skill prescribes a repository+service architecture that this codebase does not use. Do not impose it here — match the existing (fat-controller + global-helper) style unless the user explicitly asks for a refactor.

## Commands

```bash
composer install
php artisan serve
```

Frontend assets (old laravel-mix / webpack, Bootstrap 4 + jQuery + Vue 2):

```bash
npm run dev      # one-off build
npm run watch    # rebuild on change
npm run prod     # production build
```

Tests (safe to run anywhere: `phpunit.xml` forces `APP_ENV=testing` with in-memory SQLite, so they never touch the real database):

```bash
vendor/bin/phpunit
vendor/bin/phpunit --filter TestName
```

The suite is small (`tests/Feature/` — checkout OTP verification and a null-regression test that builds its own minimal schema). There is no linter/formatter configured.


CLI PHP on this dev machine works (PHP 8.4 with `mbstring`, `curl`, `pdo_mysql`), so `vendor/bin/phpunit` and `php artisan tinker <script.php>` run fine. The local MySQL database `kapitano` is a stale copy of production (useful for realistic data, but recent production rows are absent — check the newest `created_at` before drawing conclusions from it).

The CLI PHP on this Windows dev machine has `mbstring` and `curl` enabled and the suite runs. Two things are not automatic: `vendor/` may be absent (run `composer install`), and the local XAMPP MySQL may be stopped — the tests themselves use in-memory SQLite and don't need it, but running the app does.

One pre-existing failure is expected: `ProductionNullRegressionTest::test_search_works_with_preorder_disabled_and_quote_in_query` errors with `no such table: search_queries`, because that test builds its own minimal schema and never creates the search tables. It is unrelated to any pricing work.

## Git workflow

`main` is the production branch; `dev` is the integration branch. Never commit feature or fix work directly to either.

**Every new feature/fix gets its own branch — but check for an existing one first.** Before starting, list the branches:

```bash
git branch -a
```

If a branch for that work already exists (local or `remotes/origin/...`), check it out and continue on it. Only create a new branch when nothing matches:

```bash
git checkout -b feat/<short-slug>-<YYYY-MM-DD> dev
```

Naming follows what is already on the remote: `feat/…`, `fix/…`, `perf/…` with a short slug and the date (e.g. `fix/api-null-crashes-2026-08-17`, `perf/frontend-speed-2026-08-28`). Branch off `dev`. Commit and push only when the user asks.

**Never use git worktrees or create extra copies of the repo** (e.g. `../kapitano_web_tokens`). Work only inside `C:\Users\Lenovo\Desktop\kapitano_web` and switch branches in place with `git checkout` / `git switch`. If uncommitted changes block a switch, `git stash` or ask the user — never create a second folder.


## Architecture

Laravel 10, PHP 8.2. No repository/service abstraction as the primary pattern — most logic lives in controllers, Blade views, and global helpers.

### `app/Http/Helpers.php` is the architectural center

~215 global functions autoloaded via composer `files`. Almost everything routes through it:

- **Pricing**: `convert_price()`, `round_price()` / `round_system_price()`, `format_price()`, `cart_product_price()`, `home_discounted_price()`, `cart_product_tax()`, coupon/discount helpers. Pricing bugs almost always involve these plus their duplicated callers.

#### The price representation contract (read before touching any price)

Product prices are stored in the **system default currency (USD, `exchange_rate = 1`)** and displayed in **SYP**. Each product carries `products.exchange_type`, which picks *which* rate converts it:

- `market` → the currency's `real_rate`
- anything else — `exchange`, and the DB column default `kapitano`, which every existing product still has — → `exchange_rate`

There are three price forms, and mixing them is the source of nearly every pricing bug:

| Form | Meaning | Where it lives |
|---|---|---|
| `RAW` | as stored on the product/stock | `products.unit_price`, `product_stocks.price`, wholesale prices, bids |
| `BASE` | normalized to the **default** rate | `carts.price/tax`, `order_details.*`, `orders.*`, shipping/coupon settings |
| `SYP` | what the user sees | rendered output only |

Only two conversions are legal:

```
RAW  --round_system_price($raw, 5, $product)-->  BASE     (or product_price_to_syp() for RAW → SYP)
BASE --convert_price($base, 5)               -->  SYP     (single_price() = format_price(convert_price()))
```

`BASE` is the only form that may be **summed across products** — that is what makes a cart holding both a `market` and an `exchange` product total correctly. Rules that follow from this:

- Never pass a RAW price to `convert_price()` / `single_price()`; they deliberately ignore their legacy `$product` argument and always use the default rate.
- Never pass `$product` to `round_system_price()` for a value derived from an already-BASE amount (taxes computed off a BASE price, shipping, coupons, club points) — that double-converts.
- Reports that aggregate RAW columns in SQL (`product_stocks.price`, `products.unit_price/purchase_price`) must wrap them in `raw_to_base_sql()`, or they compare RAW cost against BASE revenue.
- `real_rate` is `0` in production today, so `market` currently falls back to the default rate. That fallback is deliberate — without it a market product prices at **0**.
- Rates are cached (`system_default_currency` for 86400s, plus a per-request memo of the SYP row), so after changing a rate you must `php artisan cache:clear`.

**Order amounts are shown at the rate in force on the order's date — with no DB column.** The user refused any production DB change for this, so the rate history lives in a file: `Currency::updating` appends the outgoing BASE → SYP factor with the moment it ended to `storage/app/exchange_rate_history.json` (`[{until, rate}]`, path overridable via `config('app.exchange_rate_history_path')`). `exchange_rate_at($ts)` picks the period by `orders.date` (never `created_at`, it is skewed); after the last change it is the live rate. No file = rate never changed = current rate for everything; a lost file only reverts to the old behaviour. Keep that file when redeploying or moving servers — it is the only record of past rates. A combined order is dated by its oldest child order. Switching the system default currency (`BusinessSetting::updating`) closes a period too. Separately, every rate change — any currency, `exchange_rate` or `real_rate`, currency added, default currency switched — is appended with who made it to `storage/app/exchange_rate_log.jsonl` (`log_exchange_rate_change()`), shown at Admin → Currency → "Exchange Rate Log" (`currency.rate_log`). Both go through `note_exchange_rate_change()`; neither ever blocks the save. So anything that belongs to an order — `grand_total`, `order_details.price/tax`, coupon, shipping, Paymera amounts, archived rows in `deleted_orders` — must go through `convert_order_price($base, $order)` / `single_order_price()` / `display_order_total($base, 5, $order)`, never `single_price()`/`convert_price()` (those use today's rate, which is right only for carts and products). Report totals in SQL: `SUM(x * order_rate_sql('orders'))`, rendered with `format_syp()`. The preorder module (`preorders` table) is separate and not covered.

`tests/Feature/ExchangeTypePricingTest.php` pins all of the above (runs on SQLite, no DB setup needed).

`tests/Feature/ExchangeTypeRuntimeTest.php` is a **live** test: it runs the real controllers and Blade views against the real MySQL database (cart page, checkout page, product page, admin report pages). It is skipped unless you opt in, and everything it does is wrapped in a transaction that is rolled back — it uses no DDL and never `RefreshDatabase`:

```bash
KAPITANO_RUNTIME_DB_TESTS=1 vendor/bin/phpunit --filter ExchangeTypeRuntimeTest
```

Needs XAMPP MySQL running. Note it has to set `$_SERVER['HTTP_HOST']`/`SERVER_NAME` by hand, because `getBaseURL()` and several controllers read `$_SERVER` directly instead of `request()` — that also breaks any CLI/queue context, unrelated to pricing.
- **Settings**: `get_setting()` reads the `business_settings` table (cached).
- **i18n**: `translate()` for UI strings.
- **Assets**: `uploaded_asset()` / `my_asset()` — files are referenced by upload ID, not path.

### Caching gotchas

Heavy use of long-lived caches: `Cache::rememberForever('verified_sellers_id')`, 86400s caches for products per category, `system_default_currency`, business settings. If a change to settings/products/currency "doesn't work", the cache is usually why — `php artisan cache:clear` (be deliberate on production).

### Routes are split by domain — 23 files in `routes/`

`web.php` (customer storefront), `admin.php`, `seller.php`, `api.php` + `api_seller.php` (mobile), plus feature files: `auction.php`, `pos.php`, `preorder.php`, `wholesale.php`, `delivery_boy.php`, `affiliate.php`, `club_points.php`, `otp.php`, `refund_request.php`, and per-gateway payment routes. When touching a feature, check whether it also has its own route file.

### API for mobile apps

`app/Http/Controllers/Api/V2/` mirrors much of the web controllers (its own `CartController`, `CheckoutController`, etc.) — the duplication warning applies most strongly here. Auth is Sanctum.

### Translations (i18n)

Models use a `*Translation` companion-model pattern (`ProductTranslation`, `CategoryTranslation`, …) with a `getTranslation('field', $lang)` method that falls back to the base model attribute. Store is Arabic-first; `translate()` handles UI strings.

### Hidden categories and product categories

- **Hidden categories** (`categories.is_hidden`, Admin → Categories "Shown to customers"): a hidden category *and every category under it* is left out of customer lists; its products stay on sale and direct links still open. `hidden_category_ids()` (Helpers.php, cached, closed over the sub-tree) is the source; use `Category::visible()` on every **customer-facing** category query (web and `Api/V2`) and `$category->visible_children` instead of `->childrenCategories` in customer views. Admin and seller screens keep listing everything — never add a global scope (commission and product pages read a product's category too). `Category::saved/deleted` calls `forget_category_caches()`, which also drops the cached lists (`featured_categories`, `app.*_categories`).
- A product's categories live in **two places** that older rows don't keep in sync: `products.category_id` (main: commission, home category sections) and the `product_categories` pivot (category pages list the pivot, so parents must be attached — `category_ids_with_ancestors()`). Search both. `product_categories` has **no index at all**: a correlated `whereExists`/`whereHas` on it takes ~2 minutes on the live catalogue; use `whereIn('products.id', <subquery>)`.
- Products → Bulk Category Change (`ProductCategoryBulkService`, permission `bulk_change_product_categories`) re-files many products at once, in *replace* or *add* mode; the main category goes through Eloquent so the audit log records it. Pinned by `tests/Feature/CategoryVisibilityAndBulkChangeTest.php` (SQLite) and the opt-in `CategoryVisibilityRuntimeTest` (live DB, rolled back).

### Database schema changes

Both `database/migrations/` and `sqlupdates/` (raw versioned SQL files, `v15.sql`…`v23.sql`, shipped by the CMS vendor) exist. Check both when reasoning about schema history; don't assume migrations tell the whole story.

### Other notable pieces

- `app/Services/` and `app/Utility/` both exist with overlapping roles (e.g. `OrderService` vs `CartUtility`, `SendSmsService` vs `SendSMSUtility`) — check both before adding new logic.
- Payments: many gateways bundled by the CMS, but the store actually uses **Cash on Delivery and Paymera** (`PaymeraService`, `PaymeraController`); OTP sign-in via phone/WhatsApp (`OtpService`, `app/Services/OTP/`).

### Paymera gateway (verified live 2026-08-30)

- API: `POST /api/create-payment`, `GET /api/get-payment-status/{paymentId}`, `POST /api/cancel-payment` on `config('paymera.base_url')` with HTTP basic auth. Responses are `{ErrorCode, ErrorMessage, Data}`; `ErrorCode == 0` is success. Payment status is `Data.status`: `A` accepted, `P` pending, `C` cancelled, `F` failed.
- **Amount unit**: the amount sent to Paymera is `grand_total (USD) × SYP exchange_rate` (the `currencies` table row for SYP, post-redenomination rate ≈ 138) — i.e. plain new Syrian Pounds. Do **NOT** divide by 100: real accepted payments (e.g. $32 order → 4416 SYP, status A) confirm the undivided amount is correct. A `÷100` existed briefly in the old web `rePayment` and was a bug.
- `App\Utility\PaymeraUtility::syncPaymentStatus()` is the single mark-paid path shared by web (`PaymeraController`) and mobile (`Api/V2/CheckoutController`). Both a browser `callback` (GET) and a server-to-server `trigger` (POST) hit it; trigger URLs must stay in `VerifyCsrfToken::$except`.
- Payment creation stores `payment_id` on `combined_orders`; syncing marks every `orders` row of that combined order paid. Beware: many `combined_orders` rows carry a `payment_id` but have **no `orders` rows** — paying those updates nothing.
- `performance-baselines/` holds Lighthouse configs/results used for performance work.

### Admin reports share one filter layer

`app/Services/ReportFilterService.php` + `resources/views/backend/reports/shared/` are the single source of truth for report filters (date presets and custom range, order/payment status, payment method, vendor, category, brand, city, free-text search, sort, page size, "exclude cancelled"), the KPI cards, the header with its export/print buttons, and the CSV download. When adding or touching a report, reuse them instead of hand-rolling another filter form — that copy-paste is exactly what made every report answer the same question differently.

Notes that follow from the price contract: `orders.grand_total` and `order_details.price/tax` are already **BASE**, so a report sums them raw and only converts at render time via `single_price()` (or `convert_price()` for CSV). Revenue per order line is `price + tax`; `products.num_of_sale` is a lifetime counter and can never answer "what sold last month" — join `order_details` for that.

`tests/Feature/AdminReportsRuntimeTest.php` renders every one of these reports against the real MySQL database (default filters, all filters at once, and the CSV export), same opt-in as the other runtime test:

```bash
KAPITANO_RUNTIME_DB_TESTS=1 vendor/bin/phpunit --filter AdminReportsRuntimeTest
```

### Partner stores (catalogs pulled from partner APIs)

Admin → "Partner Stores" (`manage_partner_stores`) imports another store's catalog through its API and sells it as the products of one of our **seller shops** (`partner_sources.seller_user_id`). Dawana (`DawanaDriver`) is the first; a new partner API = one class implementing `App\Services\PartnerCatalog\PartnerCatalogDriver` + one line in `PartnerCatalogDriverFactory::DRIVERS`.

- **Everything lives in `app/Services/PartnerCatalog/`** (static classes, one job each; the map is in `PartnerCatalogSync`'s docblock): `PartnerCatalogFetcher` (API → staging), `PartnerCatalogMapping` (category/brand suggestions + the per-batch lookups), `PartnerCatalogWriter` (import/apply/hide/lock/create brands), `PartnerCatalogPricing`, `PartnerCatalogSync` (whole sync for the CLI, `KNOWN_ERRORS`, cache clear). Run bookkeeping is on the model: `PartnerSyncRun::start()` / `->finish()` / `->addStats()`.
- **Single write path:** `PartnerCatalogWriter` is the only class that writes store tables (`products`/`product_stocks`/`product_translations`/`product_categories`/`uploads`/`brands`) — `ProductService::store()` is bypassed on purpose (it needs `auth()->user()`), so the admin page and `php artisan partners:sync {source?} {--fetch-only}` share the code. The flip side: its product field list copies the admin's product creation, so a new required `products` column must be added there too.
- **Three steps, all batched** (no queue worker): *fetch* (API → `partner_products`/`partner_categories`/`partner_brands` staging, store untouched), *import* (staged rows with `decision=import` and a mapped category → new products), *apply* (staged copy → already-imported products). The admin page drives them as a loop of JSON calls.
- **Product ids never change:** `partner_products.product_id` + `product_stock_id` link to the rows; sync only `update()`s them in place (never the admin path's `stocks()->delete()` + recreate), the slug is never regenerated, and a new `uploads` row is written only when the image URL changes. Nothing is ever deleted — a product is only unpublished.
- **Published = `decision == import` && `source_state == available` && its partner category is mapped** (`shouldPublish()`). `decision` is staff intent, `source_state` is what the partner reports (`no_price`, `removed`, `unsupported_currency`), kept apart so a hidden product stays hidden when the partner re-prices it. "Removed" is only set after a *complete* fetch.
- Per-source `sync_*` toggles choose the fields apply may overwrite; a **locked** row keeps all content, but its stock reset and published flag still follow the partner. Stock is synthetic (`default_stock_qty`, reset on every sync because orders count it down).
- Prices: partner price → RAW via `priceToRaw()` (default currency as-is, others through `currencies.exchange_rate`, unknown code → never imported), `exchange_type = 'exchange'`, rounded to 2 decimals like the `double(20,2)` columns. `purchase_price = price × (1 − cost_discount_percent/100)`; the selling price is the partner's.
- Descriptions pass `sanitize_partner_html()` (the product page echoes them raw). The API key uses the `encrypted` cast — re-enter it if `APP_KEY` changes.
- Partner text that reaches a store table goes through the global `strip_4byte_chars()` (`Helpers.php`): `products`, `product_translations`, `brands`, `uploads` (and most old CMS tables) are **utf8mb3**, and a 4-byte character (emoji) fails an insert *and* a `WHERE` on them with MySQL error 3988. The `partner_*` staging tables are utf8mb4 and keep the partner's text as is. The storefront search and the admin product editor do not use it yet, so an emoji there still fails.

`tests/Feature/PartnerStoreSyncTest.php` pins all of the above with `Http::fake()` on SQLite.
