# 02 — Pricing, currency, discounts, coupons, tax, shipping

> Snapshot of branch `docs/codebase-map-2026-09-29` (= `origin/dev`), written 2026-09-29.
> Line numbers are for that snapshot and drift quickly; search by function name if they are off.
> Claims marked **(unverified)** were inferred and not confirmed in code, data or production.

## 1. Purpose & scope

This covers every place that turns a stored number into money a customer pays or a report shows:

- the three price forms (RAW / BASE / SYP) and the helpers that convert between them (`app/Http/Helpers.php`)
- the `currencies` table, `products.exchange_type` (`market` vs everything else), the file-based exchange-rate history (the rate frozen at order time) and the rate-change log
- product-level discounts (normal discounts, flash deals, today's deal, **fake discounts** through `FakeDiscountService`), wholesale tiers, taxes
- cart → checkout → order totals on **web** and **API V2 (mobile)**, including the Paymera app checkout snapshot
- coupons: every type, platform / allowed-users / max-users limits, single use, recalculation after an order is edited, compensation coupons, welcome coupons, the usage report and its backfill command
- shipping cost: every `shipping_type` mode, area (city) costs, free-shipping thresholds
- club points, only as far as they touch price

Out of scope: the preorder module (`preorders`, `preorder_*` helpers at Helpers.php:4746-4913, its own coupons and discounts), seller commission and payouts (only touched where `product_cost_price()` lives), how payment gateways work inside (see the Paymera notes in CLAUDE.md).

## 2. Key files

| Path | Role | Notable functions (line) |
|---|---|---|
| `app/Http/Helpers.php` | All price-form conversions, cart/product price helpers, shipping | `get_system_default_currency` 305, `get_syp_currency` 314, `round_price` 332, `product_exchange_type` 380, `currency_syp_factor` 413, `market_default_rate` 450, `safe_rate_ratio` 461, `product_syp_factor` 488, `round_system_price` 509, `convert_price` 548, `product_price_to_syp` 570, `product_commission_percentage` 585, `product_cost_price` 625, `product_profit_sql` 655, `format_system_currency_price` 717, `raw_to_base_multiplier` 735, `raw_to_base_sql` 761, `currency_symbol` 777, `format_price` 797, `single_price` 840, `display_order_total` 851 |
| same | Rate frozen at order time + rate log | `exchange_rate_history` 896, `record_exchange_rate_change` 934, `log_exchange_rate_change` 993, `exchange_rate_log` 1041, `note_exchange_rate_change` 1065, `exchange_rate_at` 1083, `order_timestamp` 1104, `order_exchange_rate` 1147, `convert_order_price` 1176, `single_order_price` 1192, `order_rate_sql` 1203, `format_syp` 1228 |
| same | Order totals | `order_is_cancelled` 1242, `order_payable_total` 1253, `combined_order_payable_total` 1272 |
| same | Cart/product price | `shipping_price` 1303, `discount_in_percentage` 1310, `cart_product_price` 1334, `cart_product_tax` 1411, `cart_product_discount` 1471 (no callers), `carts_product_discount` 1514 (no callers), `carts_coupon_discount` 1563 (no callers), `product_default_stock` 1644, `home_price` 1670, `home_discounted_price` 1730, `home_base_price_by_stock_id` 1808, `home_base_price` 1829, `home_discounted_base_price_by_stock_id` 1852, `home_discounted_base_price` 1894 |
| same | Shipping | `cart_shipping_base_total` 2042, `first_shippable_cart_index` 2070, `free_shipping_limit_for` 2097, `free_shipping_progress` 2119, `cart_shipping_cost` 2152, `getShippingCost` 2209, `carrier_base_price` 2412, `seller_base_carrier_list` 2427 |
| same | Misc | `convert_to_usd_from_syp` 122 (10-min cache), `get_system_currency` 3395 (forces session currency to SYP), `get_active_taxes` 3338, `get_featured_flash_deal` 3304, `get_user_total_club_point` 4143, `calculateCommissionAffilationClubPoint` 3200, `get_coupons` 4195, `offerUserWelcomeCoupon` 4342, `ifUserHasWelcomeCouponAndNotUsed` 4370 |
| `app/Models/Currency.php` | Logs rate changes, closes a rate period | `booted` 16 (`updating`, `created`), `noteRateChange` 39, `orderFactors` 64, `marketFactors` 96 |
| `app/Models/BusinessSetting.php` | Switching the default currency closes a rate period | `booted` 17 |
| `app/Http/Controllers/CurrencyController.php` | Admin currency CRUD, rate log page | `updateYourCurrency` 41, `rateLog` 64, `store` 96 |
| `app/Utility/CartUtility.php` | Price written to `carts` at add-to-cart | `get_price` 88, `discount_calculation` 113, `tax_calculation` 138, `save_cart_data` 154, `orderable_items` 34 |
| `app/Http/Controllers/CartController.php` (web) | Cart page, add/update/select | `index` 64 (zeroes `shipping_cost` 82-87), `addToCart` 107 (price 194), `updateQuantity` 303, `updateCartStatus` 362 |
| `app/Http/Controllers/Api/V2/CartController.php` | App cart | `summary` 28, `getList` 98, `add` 362 (price 469), `changeQuantity` 549, `updateCartStatus` 636 |
| `app/Http/Controllers/CheckoutController.php` (web) | Checkout page, coupon apply/remove, Paymera redirect | `index` 352, `checkout` 466 (min order 503, Paymera 562-609), `store_delivery_info` 865, `apply_coupon_code` 943, `remove_coupon_code` 1079, `updateDeliveryAddress` 1225, `updateDeliveryInfo` 1289 |
| `app/Http/Controllers/OrderController.php` (web + admin) | Web order creation, admin order edits | `store` 509 (shipping 737), `updateQty` 343, `deleteItem` 418, `combined_order_details` 319, `destroy` 872 (recalc 898) |
| `app/Http/Controllers/Api/V2/CheckoutController.php` | App coupon apply/remove, Paymera app checkout | `apply_coupon_code` 45, `createPayment` 502, `rePay` 373, `remove_coupon_code` 871 |
| `app/Http/Controllers/Api/V2/OrderController.php` | App COD order creation | `store` 38 |
| `app/Utility/PaymeraAppCheckoutUtility.php` | App Paymera: totals snapshot, orders built after payment | `buildSnapshot` 44, `complete` 110, `createOrders` 210 |
| `app/Http/Controllers/Api/V2/ShippingController.php` | App shipping choice | `shipping_cost` 23, `getDeliveryInfo` 89 |
| `app/Http/Controllers/Api/V2/AddressController.php` | Recomputes cart shipping when the app changes address (~190-210) | — |
| `app/Utility/CouponUtility.php` | Whether a coupon may be used; product/category-scoped coupons | `usedByUser` 51, `redeemedUserCount` 70, `maxUsers` 132, `userLimitReached` 143, `platformOf` 161, `isActive` 184, `rejectionReason` 199, `allowsUser` 250, `scopeForUser` 270, `syncAllowedUsers` 301, `recordUsage` 315, `scopeForPlatform` 344, `coveredProductIds` 410, `scopedLineDiscount` 451, `scopedCartDiscounts` 473, `applyScopedCoupon`, `refreshScopedCoupon`, `isCartWide`, `cartWideDiscount`, `refreshCartCoupon`, `applyCartWideCoupon`, `removedMessage` |
| `app/Utility/CombinedOrderCouponUtility.php` | Coupon on a placed order; recalculation after edits | `recalculateForOrder` 34, `recalculate` 56, `eligibleAmount` 121, `decide` 172, `computeDiscount` 238, `welcomeBaseDiscount` 286, `scopedOrderDiscounts` 330, `allocate` 369, `storeCheckoutCoupon` 524, `scopeCouponDiscount` 539 |
| `app/Utility/CompensationCouponUtility.php` + `CompensationCouponController.php` | 200 SYP customer-service coupon | `amountInBase` 114, `issue` 130, `isUsed` 191, `sypFactor` 203 |
| `app/Http/Controllers/CouponController.php` | Admin coupons **and** fake-discount admin | discounts 47-235, coupons `store` 271, `update` 321, `updateStatus` 520 |
| `app/Http/Requests/CouponRequest.php` | Builds `coupons.details` JSON per type | `rules` 28, `prepareForValidation` 113 |
| `app/Http/Controllers/Api/V2/CouponController.php` | App coupon list; `apply` is **dead** (route commented, api.php:571) | `couponList` 179, `getCouponProducts` 191 |
| `app/Services/FakeDiscountService.php` | Fake discount: inflate/restore prices | `resync` 34, `attach` 88, `currentState` 141, `inflate` 184, `restore` 203, `payablePrice` 221, `health` 266 |
| `app/Http/Controllers/FlashDealController.php`, `app/Services/ProductFlashDealService.php` | Flash deals write `products.discount*` | store 63-95, update 156-178, destroy 202-212 |
| `app/Services/CouponUsageReportService.php`, `Admin/Report/CouponReportController.php`, `app/Exports/CouponUsageReport*` | Coupon usage report | `kpis` 308, `ordersQuery` 548 |
| `app/Console/Commands/BackfillOrderCoupons.php` | `report:backfill-order-coupons {--dry-run} {--window=180}` | `handle` 29 |
| `app/Http/Controllers/CityController.php` | Area cost `cities.cost` and free-shipping threshold `cities.carttotal` | `store` 56, `update` 96, `bulkCost` 144 |
| `resources/views/frontend/partials/cart/cart_summary.blade.php` | Web cart/checkout totals (repeats the math in the view) | 316-358, 431-442 |
| `resources/views/frontend/product_details/details.blade.php` | Product page price, wholesale table | 259 |

## 3. Price forms and conversions

### 3.1 The contract (CLAUDE.md, checked against code)

| Form | Meaning | Lives in |
|---|---|---|
| RAW | as stored on the product, read with the product's own exchange rate | `products.unit_price`, `products.purchase_price`, `products.real_price`, `product_stocks.price/real_price`, `wholesale_prices.price`, `auction bids`, product `discount` when `discount_type = amount`, `product_taxes.tax` when `tax_type = amount` (see 8.3) |
| BASE | system default currency (USD, `exchange_rate = 1`) at the **default** rate; the only form you may add up across products | `carts.price` (mostly, see 8.1), `carts.tax/discount/shipping_cost`, `order_details.price/tax/shipping_cost`, `orders.grand_total/coupon_discount`, `combined_orders.*`, coupon `discount/min_buy/max_discount`, `cities.cost/carttotal`, `flat_rate_shipping_cost`, `shipping_cost_admin`, `shops.shipping_cost`, `products.shipping_cost` (treated as BASE), `minimum_order_amount`, wallet balances |
| SYP | rendered output only | `convert_price()` result, `format_price()` input |

Checked and true:
- `product_exchange_type()` (380) returns `market` only for the literal `market` (any case, trimmed); `exchange`, `kapitano`, null, `''` all become `exchange`. `ProductRequest` accepts `exchange|market|kapitano` (ProductRequest.php:41). No migration or `sqlupdates` file defines `exchange_type`, `real_rate` or `real_price`. They were probably added straight on the server **(unverified)**; the claim that the column default is `kapitano` is also **(unverified)**.
- `currency_syp_factor('market')` = `syp.real_rate / market_default_rate(usd.real_rate, usd.exchange_rate)`. `market_default_rate` (450) uses the USD `exchange_rate` when USD `real_rate` ≤ 0. If SYP `real_rate` ≤ 0 the whole factor falls back to the `exchange` factor (428-439). So "real_rate is 0 → market prices like exchange" holds.
- `convert_price()` / `single_price()` ignore `$product` (548, 840). True.
- `system_default_currency` is cached 86400 s (308). The SYP row is memoized **per request only** (314-328), so a change to the SYP rate takes effect on the next request with no `cache:clear`. A change to the **default** currency's rate, or switching the default currency, waits for the cache: nothing calls `Cache::forget('system_default_currency')` (grep shows no caller). CLAUDE.md says "after changing a rate you must `cache:clear`". That is only needed for the default-currency row, but it is harmless.
- CLAUDE.md "Admin reports" note (line 182) says reports convert BASE sums with `single_price()`. That **contradicts** the frozen-rate rule in the same file (line 110): order money should go through `order_rate_sql()` + `format_syp()`. The newer reports (`CouponUsageReportService`, `PlatformMetricsService`, `ReportFilterService`-based) follow line 110. The dashboard does not (see 8.4).

**Seller price currency (2026-10-25).** RAW is in `products.price_currency` (NULL = USD, every product until staff switch its seller; CLAUDE.md "Seller price currency", ADR 0005). The hinge is `product_syp_factor()` (SYP → factor 1, another currency C → the exchange-type factor ÷ C per dollar) and `raw_to_base_sql()` (one `CASE` branch per currency in `CurrencyRates::inUse()`; byte-identical SQL while none is in use). BASE stays USD everywhere, so the rest of this file holds. New helpers: `product_price_currency_code()` / `product_price_currency()`, `product_raw_to_base_multiplier()`, `product_raw_to_default()`, `product_tax_amount_to_base()`, `order_by_product_price()`, `format_product_currency_price()`. Fixed in the same change: RAW passed to `single_price()` (bids, wholesale, digital page, product search, app POS/auction/wholesale resources), price sorts on bare `unit_price`, the web cart quantity change writing RAW into `carts.price`, and flat taxes added to BASE prices.

### 3.2 Conversion functions (exact behaviour)

| Function | In → out | Behaviour |
|---|---|---|
| `round_price($p, $nearest=5)` 332 | any → same | `round($p / max(1,$nearest)) * $nearest`. PHP `round` rounds halves away from zero. |
| `round_system_price($raw, $nearest=5, $product=null)` 509 | RAW → BASE | `round_price($raw * product_factor, $nearest) / default_factor`. The one rounding happens **in SYP**. Without `$product` (or with a non-market product) `product_factor == default_factor`, so it acts as "round a BASE value to the nearest `$nearest` SYP". Non-finite → 0. |
| `convert_price($base, $unit=5)` 548 | BASE → SYP number | `round_price($base * currency_syp_factor('exchange'), $unit)`. Gives the same result when fed the output of `round_system_price`. |
| `product_price_to_syp($raw, $product, $unit=5)` 570 | RAW → SYP number | `convert_price(round_system_price(...))` |
| `format_price($syp, $isMinimize=false)` 797 | SYP number → string | `number_format` with `no_of_decimals`; `decimal_separator` 1 = `,` for thousands, anything else = `.` thousands / `,` decimal; `symbol_format` 1 prefix, 3 prefix+space, 4 suffix+space, other suffix; symbol from `currency_symbol()` = SYP row symbol or `ل.س`. **Does not convert.** |
| `single_price($base)` 840 | BASE → string | `format_price(convert_price($base))` |
| `display_order_total($base, 5, $order=null)` 851 | BASE → string | clamps negatives to 0; with `$order` uses the order's frozen rate |
| `shipping_price($base)` 1303 | BASE → string | alias of `single_price` |
| `format_system_currency_price($amount)` 717 | RAW/BASE → `$1,234.00` | admin only, no SYP |
| `raw_to_base_multiplier($type)` 735 | factor | `factor(type) / factor('exchange')`, 1 for non-market |
| `raw_to_base_sql($col, $typeCol='products.exchange_type')` 761 | SQL | `($col * CASE WHEN $typeCol = 'market' THEN <m> ELSE 1 END)`, no rounding. The multiplier is baked in as a literal when the query is built. |
| `exchange_rate_at(?int $ts)` 1083 | factor | first history period with `$ts < until` → its `rate`, else the live `currency_syp_factor('exchange')`. Only the **exchange** factor is frozen; market products were already turned into BASE at order time. |
| `order_timestamp($order)` 1104 | epoch | `DeletedOrder` → `order_data['date']` then `order_created_at`; `CombinedOrder` → `min(orders.date)` then `created_at`; other objects → `->date` then `created_at` |
| `order_exchange_rate($order)` 1147 | factor | numeric arg = that rate; `OrderDetail` → its order; object with a positive `exchange_rate` attribute (a report row from `order_rate_sql() as exchange_rate`) → used as is; else `exchange_rate_at(order_timestamp())` |
| `convert_order_price($base, $order, 5)` 1176 / `single_order_price` 1192 | BASE → SYP at the order's date | use for anything that belongs to an order |
| `order_rate_sql('orders')` 1203 | SQL | `(CASE WHEN orders.date < until1 THEN r1 ... ELSE current END)`, or just the current factor when there is no history |
| `format_syp($syp)` 1228 | SYP → string | for SQL totals already multiplied by `order_rate_sql` |
| `order_payable_total($order)` 1253 | BASE | 0 if `delivery_status = cancelled`, else `grand_total` |
| `combined_order_payable_total($co, $orders)` 1272 | BASE | Σ non-cancelled `orders.grand_total` + `combined_orders.shipping_cost`; all cancelled → 0; no child rows → stored `grand_total` |
| `convert_to_usd_from_syp($amount)` 122 | SYP → USD | cached 10 min under `currency_rates_syp_usd`; used for gateway amounts **(unverified which callers)** |

The rate history file is `storage/app/exchange_rate_history.json`, overridable with `config('app.exchange_rate_history_path')`, shape `[{until, rate}]`. It is written only by `record_exchange_rate_change()` (934, under `flock`) through `note_exchange_rate_change()` (1065), which does so when the BASE→SYP **order factor** changes by more than 1e-9. That happens on:
- `Currency::updating` when `exchange_rate` of SYP or of the default currency is dirty (`Currency::orderFactors` 64; ignored when SYP *is* the default)
- `BusinessSetting::updating` for `system_default_currency` (BusinessSetting.php:21)

Every change to `exchange_rate` or `real_rate` on any currency, and every currency creation, is appended to `storage/app/exchange_rate_log.jsonl` with the user, the impersonator, the IP and the market-factor delta (`log_exchange_rate_change` 993). Admin page: `CurrencyController@rateLog` → `backend.setup_configurations.currencies.rate_log`. Neither write can block the save; both catch `Throwable`.

## 4. Flows

### 4.1 Product page / listings (display only)

- Cards (web views + API `ProductMiniCollection`, `ProductCollection`, `SearchProductCollection`, `LastViewedProductCollection`, `FlashDealProductCollection`, `WholesaleProductCollection`, ~50 Blade files): `home_base_price($p)` (stroked) and `home_discounted_base_price($p)` (main). Both start from `product_card_raw_price($p)` (since 2026-11-05: the price of the variant the product page opens on - the first one in stock walking colours and options in display order; `unit_price` when nothing is in stock or the product has no variants; the price range filter still reads `unit_price`), apply the running discount, add taxes **in RAW**, then `product_price_to_syp`.
- Product details: `home_price($p, true, $stock)` / `home_discounted_price(...)` give a low–high range over `stocks`, or one stock when `$stock` is passed (`product_default_stock()` 1644 picks the stock the page pre-selects). The API `ProductDetailCollection:101` builds `price_high_low` by splitting these strings on `-`.
- Variant or quantity change: web `HomeController@variant_price` 1876 and API `Api/V2/ProductController@getPrice` 196 repeat the cart math: stock price → wholesale tier by quantity → discount → `round_system_price(...,$product)` → tax on BASE → `single_price($price * qty)`.
- Discount badge: `discount_in_percentage()` returns the running discount as a whole percentage of `unit_price`, `amount` discounts included (since 2026-11-04; before they showed 0%), and 0 for an upcoming or ended discount (it uses `product_discount_state()`).
- Wholesale table on the product page: `single_price($wholesalePrice->price)` (details.blade.php:259) and API `ProductWholesaleResource:23`. **Both pass RAW to a BASE function** (see 8.2).
- Price filter/sort: `HomeController` listing (~1702-1728) uses `raw_to_base_sql('products.unit_price')` and SYP bounds ÷ factor. `SearchController` 124-161 and 212-272 compare `unit_price` (RAW USD) with `min_price/max_price` directly, and API `ProductController` 530-534 sorts by raw `unit_price` (see 8.2).

### 4.2 Cart price

1. Add to cart: web `CartController@addToCart` 194 and API `CartController@add` 469 → `CartUtility::get_price()` (stock price, or the highest auction bid; wholesale tier for the **requested** quantity; then product discount; then `round_system_price(...,5,$product)`) → `tax_calculation` (on BASE) → `save_cart_data` writes `carts.price/tax` (BASE).
2. Web quantity change (`CartController@updateQuantity` 303) rewrites `carts.price` with a **RAW** value, applying the discount *before* the wholesale override (346). See 8.1.
3. Every total shown or ordered **recomputes** from the product with `cart_product_price($cart, $product, false, false) * qty` and `cart_product_tax(...) * qty`. `carts.price` is mostly decorative (except the paths listed in 8.1).
4. `cart_product_price` 1334: missing product or stock → 0; stock price → wholesale tier for the **cart** quantity → discount (percent or amount, if no start date or now inside [start,end]) → `round_system_price(...,5,$product)`. Auction → highest bid → `round_system_price`. `$tax=true` adds the tax (percent of BASE + amount) rounded without the product.
5. Web cart summary (`cart_summary.blade.php` 316-442): subtotal + tax + shipping (`cart_shipping_cost($carts)` unless `$proceed == 1`, then 0) − `carts.sum('discount')` − `Session::club_point`, shown with `display_order_total`.
6. App cart: `getList` 98 (per shop; free-shipping banner from the **default address**), `summary` 28 (shipping from `cart_shipping_cost($items)` using the **cart's** `address_id`; discount = Σ `carts.discount`).

### 4.3 Checkout totals

Web:
- `CheckoutController@index` 352: puts the default address on every active cart, writes per-row `shipping_cost` with `getShippingCost` (423-437), then **overwrites** `$shipping` with `getShippingCost($carts, 0, ...)` (440-446). `$total` goes to the view only for the wallet-balance check and the analytics pixel. The visible summary is `cart_summary` with `proceed = 0`.
- `CheckoutController@checkout` 466: `minimum_order_amount_check` on the tax-free subtotal (503-513, web only; the API check is commented out at Api/V2/OrderController:309) → `OrderController@store` → delete the active carts → payment. Paymera: amount = `(int)(combined.grand_total * order_exchange_rate($combined))` (578). A zero total skips the gateway (`PaymeraUtility::nothingToCharge`).
- `OrderController@store` 509: `orderable_items` over **active** carts → re-check the coupon (`rejectionReason(..., PLATFORM_WEB)`) → `refreshCartCoupon` (coupon minimum, covered products) → one `Order` per seller with `order_details.price/tax` from `cart_product_*` and `shipping_cost = 0` → `orders.grand_total = subtotal + tax − Σ carts.discount` → `recordUsage` → `storeCheckoutCoupon` → **`combined_orders.shipping_cost = round_system_price(cart_shipping_cost($carts), 1000)`** (737), added to the combined `grand_total`. Not wrapped in a DB transaction; stock is not locked.

API (COD): `Api/V2/OrderController@store` 38: same re-checks with `PLATFORM_APP` → `cart_shipping_cost($cartItems, null, true)` (writes per-row costs) → inside `DB::transaction` with `lockForUpdate` on stocks → `order_details.shipping_cost = 0`, `$shipping += round_system_price($cart->shipping_cost)` (unit **5**) → `combined.shipping_cost = $shipping` → deletes **all** of the user's carts, selected or not (276).

API (Paymera): `Api/V2/CheckoutController@createPayment` 502 → the same re-checks → `cart_shipping_cost(..., true)` → `PaymeraAppCheckoutUtility::buildSnapshot` (per-seller `grand_total` **includes shipping**) → an empty `CombinedOrder` with `grand_total = snapshot` and `shipping_cost = Σ carts.shipping_cost` → after the payment is accepted, `createOrders` writes `order_details.shipping_cost = item shipping` and `orders.grand_total` including shipping (see 8.1, shipping counted twice).

### 4.4 Coupon application

> **2026-10-20: the coupon of a placed order lives on the combined order only.** It is no longer split over the seller orders (`orders.coupon_discount = 0`, `orders.grand_total` without coupon); `combined_orders.coupon_discount` is the coupon, judged on the live orders and judged again on every cancellation / return (`CombinedOrderCouponUtility::afterOrderStatusChange()`). What the customer pays: `combined_order_payable_total()`; per seller order (collection, refunds, lists): `order_customer_amount()` / `order_coupon_share()` (worked out, never stored); in SQL `order_paid_sql()` / `order_coupon_share_sql()`. Platform totals subtract it, seller figures do not. Old rows moved by migration `2026_10_20_000002`. The full rule is in CLAUDE.md ("The checkout's coupon lives on the combined order"); everything below about per-order allocation describes the cart and orders placed before.

Web `CheckoutController@apply_coupon_code` 943:
1. `welcome_base` needs a logged-in user with `$user->userCoupon`; its validity is `user_coupons.expiry_date`. Every other type checks `start_date <= today(00:00) <= end_date`.
2. `CouponUtility::rejectionReason($coupon, $userId, 'web')` covers: active status (welcome exempt), platform, allowed users, already used, new customers only, max users.
3. `cart_base` / `welcome_base` go through `CouponUtility::applyCartWideCoupon` (since 2026-10-17): base = the **selected** rows only, Σ `cart_product_price` + Σ `cart_product_tax`, never shipping (it used to count unselected rows too, so an unselected expensive item could carry a cart over the minimum).
4. `cart_base`: `sum >= min_buy` → percent (capped at `max_discount`) or amount. `welcome_base`: `sum >= user_coupon.min_buy` → percent (**no cap**) or amount (`CouponUtility::cartWideDiscount`). `product_base`/`category_base`: `CouponUtility::applyScopedCoupon` writes each covered row's own discount. `governorates_base`: **not handled on the web** (discount stays 0 → "not applicable").
5. The cart-wide discount is spread evenly over the selected rows; unselected rows carry the code with 0, so re-selecting them keeps the coupon.

API `Api/V2/CheckoutController@apply_coupon_code` 45 (`auth:sanctum`, but it reads **`$request->user_id`**, not the authenticated user):
- carts = the **active** carts of `user_id`; checks dates and `rejectionReason(..., 'app')`.
- `cart_base`: `CouponUtility::applyCartWideCoupon` over all rows of the cart, exactly like the web.
- scoped: `applyScopedCoupon` over all rows.
- `governorates_base` 139: the default (or first) address's `state_id` must be in `details[].governorate_id`; percent/amount on subtotal+tax+(0); **no min_buy, no max_discount**.
- `welcome_base` 208: uses undefined `$couponDetails` and returns `success` instead of `result`. It never writes the cart (a broken branch, see 8.2).
- `governorates_base` still spreads `discount / count(active rows)` over **all** rows.

**The coupon on the cart is judged again on every change and at order time** (`CouponUtility::refreshCartCoupon`, since 2026-10-17): web `CartController` removeFromCart / updateQuantity / updateCartStatus, API `changeQuantity` / `process` / `destroy` / `updateCartStatus`, and the three order paths (web `OrderController@store`, `Api/V2/OrderController@store`, `Api/V2/CheckoutController@createPayment`). A cart-wide coupon whose selected rows fell below `min_buy` (quantity lowered, product removed or unselected, product unpublished and hidden by the cart scope) is taken off; at order time the customer gets `CouponUtility::removedMessage()` and is sent back to the cart. Before this, `min_buy` was only checked when the coupon was applied, so a customer could apply it above the minimum and then shrink the cart. A percentage coupon follows the new total. `governorates_base` is not refreshed (no minimum).

### 4.5 Shipping cost

`getShippingCost($carts, $index, $shipping_info, $carrier)` 2209, by `get_setting('shipping_type')`:

| Mode | Per-row result (BASE) |
|---|---|
| `flat_rate` | `flat_rate_shipping_cost / count($carts)` |
| `seller_wise_shipping` | admin products: `shipping_cost_admin / #admin rows`; seller: `shops.shipping_cost / #rows of that seller` |
| `area_wise_shipping` | 0 if `cities.carttotal > 0` and `cart_shipping_base_total >= carttotal`; else `cities.cost` on the **first shippable row only** (`first_shippable_cart_index`, which skips pickup-point and digital rows), 0 on the others |
| `carrier_wise_shipping` | zone from `countries.zone_id`; the carrier's first range decides `weight_based` vs price; the matching range `[d1,d2)` price for the zone ÷ number of rows of that owner |
| anything else (`product_wise_shipping`) | `products.shipping_cost`, × quantity when `is_quantity_multiplied` |

Digital products → 0. Every branch is wrapped in `round_system_price()` **without the product**, so product shipping costs are read as BASE even for market products.

**Cost of the engine (2026-10-21).** `getShippingCost` runs once per row and walks every row; it used to `Product::find()` each product on each walk (4 queries each with `Product::$with`), plus a `City` per call, so a 10-row cart cost ~1,000 queries in `shipping_cost` and ~460 in `cart-summary`. Products now come from the row's loaded `product` relation or `shipping_cart_product()` (one `whereIn` with stocks for every unloaded product of the cart, kept for the request), and the city, shop, country and carrier are read once per request (`request_memo('shipping.*')`). Callers still call it once per row - several change `shipping_type` between calls - and the persist loop still saves every row. Every type is pinned by `ShippingEngineCharacterizationTest` (+ its query budget: the cost no longer grows with the rows).
`cart_shipping_cost($carts, $info=null, $persist=false)` 2152 is the preferred total: it derives `$info` from the cart's `address_id` or `Session::guest_shipping_info`; with neither it returns Σ stored `shipping_cost`; pickup rows → 0.
`free_shipping_progress($carts, $city)` 2119 feeds every free-shipping banner (web add-to-cart modal, web cart page, app `getList`/`add`). The web cart page bar is `cart_free_shipping_bar($user, $active_carts)` (partial `frontend/partials/cart/free_shipping_bar`, included at the top of `cart_details`, so it is redrawn on every quantity/remove/select change) and copies the app's rules: customers only, default address else the first one, no city = empty bar + "add a shipping address", an area without a limit = no bar, reached = green bar; every message is the same text the app receives. The cart page summary (`cart_summary` with `proceed = 1`) also shows the shipping now, from `cart_page_shipping($user, $active_carts)`: the same address (`cart_shipping_address()`, shared with the bar) priced by `cart_shipping_cost()` with that address's area, so it equals what checkout charges once that address is picked; it is added to the cart total and reads "مجاني" at 0; a guest gets a link that opens `#phoneLoginModal` ("سجّل الدخول لمعرفة سعر الشحن"), a customer without an address a link to `profile` ("أضف عنوان الشحن لمعرفة سعر الشحن") - both add 0. Checkout (`proceed = 0`) is unchanged. Base = `cart_shipping_base_total` = Σ tax-free line prices of non-digital rows, the same number `getShippingCost` uses.

### 4.6 Rate frozen at order time

Orders store BASE only. Displaying an order uses `single_order_price($base, $order)` / `display_order_total($base, 5, $order)` / `convert_order_price`. Paymera amounts use `order_exchange_rate($combined)`. Report SQL uses `SUM(x * order_rate_sql('orders'))` → `format_syp`. The factor for a new, unsaved order is the live rate. `DeletedOrder` archives resolve through `order_data['date']`.

### 4.7 Reports

- Coupon usage (`CouponUsageReportService`): every money column uses `order_rate_sql('orders')`; rows carry `exchange_rate` so `order_exchange_rate($row)` short-circuits. `orders.coupon_id IS NULL` with a discount > 0 = "Unknown (legacy order)"; `report:backfill-order-coupons` fills them from `coupon_usages` rows of the same user within ±`window` seconds, only when exactly one coupon matches.
- Profit, stock and vendor reports in `ReportController` normalize RAW cost with `raw_to_base_sql` (578-580, 1693, 1915, 2105, 2718; `SalesProfitReportExport:224`). The stock-value CSV uses `convert_price` (2701) because stock value is not an order amount.
- Admin product profit sort: `product_profit_sql()` / `apply_product_list_sort()` (655, 692).

## 5. Coupon types reference

`coupons`: `user_id` (admin id for admin coupons, seller id for seller coupons), `type`, `code` (unique), `discount`, `discount_type` (`amount`|`percent`), `details` JSON, `start_date`/`end_date` (epoch; the date-range picker values go through `strtotime`), `status`, `visible`, `platform` (`web|app|both`, migration 2026_09_09), `max_users` (2026_09_17_000004, kept out of `$fillable`), `new_customers_only` (2026_10_17_000002, kept out of `$fillable`, saved by `CouponController::saveNewCustomersOnly`). Related tables: `coupon_allowed_users` (2026_09_17_000002), `coupon_usages(order_id)` and `orders.coupon_id/coupon_code` (2026_09_05), `combined_orders.coupon_id/coupon_code/coupon_discount` (2026_09_06), `user_coupons` (welcome).

| Type | `details` | Base amount | Rules | Web apply | App apply | After an order edit (`CombinedOrderCouponUtility`) |
|---|---|---|---|---|---|---|
| `cart_base` | `{min_buy, max_discount}` (both required ≥ 1) | selected rows: subtotal+tax, no shipping; re-judged on every cart change and at order time | `sum >= min_buy`; percent capped at max | yes | yes (shipping always 0) | subtotal+tax, **no shipping**; min_buy re-checked; cap applied |
| `product_base` | `[{product_id}]` | each covered line, tax-free | percent of the line, or amount × qty, capped at the line | yes (scoped rows) | yes | re-derived per covered detail |
| `category_base` | `[{category_id}]`, the chosen categories plus all their children, through `products.category_id` **or** `product_categories` | as above | as above | yes | yes | as above |
| `welcome_base` | `{min_buy, validation_days}`, copied into `user_coupons` at signup by `offerUserWelcomeCoupon()` | selected rows: subtotal+tax, no shipping; re-judged like cart_base | `user_coupons.expiry_date`; percent **uncapped** on the web | yes | branch broken | uses `user_coupons` values; cap only if `user_coupons.max_discount` is set (that column is never written) |
| `governorates_base` | `[{governorate_id}]` (= `states.id`) | app: subtotal+tax | address `state_id` must match; no min, no cap | **not supported** | yes | flat, no min_buy |
| compensation (a `cart_base`) | `{min_buy:0, max_discount:amount, compensation:{order_id, order_code, combined_order_id, customer_id, issued_by, issued_at, amount_syp, rate, reason}}` | — | code `COMP` + 6 characters, 30 days, `max_users=1`, `platform=both`, allowed user = the order's customer. Refused when `coupon_allowed_users` is missing | yes | yes | as cart_base |

Rules that apply to every type (`CouponUtility::rejectionReason` 199, used at apply time and again at order time):
- `isActive`: `status == 1`, except `welcome_base`, where `status` only chooses which welcome coupon new signups get.
- Platform: `null`/`''`/unknown = both.
- Allowed users: a coupon with rows in `coupon_allowed_users` is only for those users and never for guests. Listings use `scopeForUser`. The admin form picks them through `coupon.search_customers` (`GET admin/coupon/search-customers`), which must stay registered **before** `Route::resource('coupon')` in `routes/admin.php`: after it, the resource's `coupon/{coupon}` (show, empty) answered the search and no customer could be picked (fixed 2026-10-13).
- Single use per customer: a `coupon_usages` row (except rows tied to a void order) **or** an `orders.coupon_id` on a live order. Void = `void_order_statuses()` (cancelled, returned; `CouponUtility::voidStatuses()`): cancelling an order or returning it **fully** gives the coupon back (returns since 2026-11-07), and frees its place under `max_users`. A partial return leaves the order live, so the coupon stays used; a checkout split over several sellers carries the coupon on every seller order, so it comes back only once all of them are void. Pinned in `CouponSingleUseTest`.
- `max_users`: counts distinct redeeming customers the same way; a customer already counted is not blocked by the limit.
- `new_customers_only` (since 2026-10-17, switch "New customers only" on the admin form, badge on the list): only a signed-in customer with no live order may redeem it. `CouponUtility::hasLiveOrders()` counts every `orders` row of the user whose `delivery_status` is not in `void_order_statuses()` (cancelled, returned), pending and unpaid included, so two checkouts in a row cannot both be "the first". A guest is refused at apply with "sign in". Checked in `rejectionReason()` (after "already used"), so the apply endpoints and the three order paths enforce it with every other condition. Listings (web coupons page, app `couponList`/`couponList1`) hide it from a customer who already ordered through `scopeForNewCustomers()`; guests still see it. The website card says "For your first order only"; the app list carries `new_customers_only`. Not covered: the global app popup (no customer context) advertises such a coupon to everyone; seller shop coupon lists (`get_coupons()`), since sellers cannot set it. Pinned by `CouponNewCustomersOnlyTest`, cases in `CouponAllowedUsersTest` and `CouponProductScopeTest`.
- `coupon_system` (setting) only hides UI (`cart_summary.blade.php:339`, views). The apply endpoints do not check it.
- Seller coupons (`coupons.user_id` = seller): neither apply endpoint filters rows by `owner_id` any more (the old filters are commented out, CheckoutController:987, Api/V2/CheckoutController:58). A seller's coupon therefore discounts the whole multi-seller cart **(behaviour verified in code; whether seller coupons are used in production is unverified)**.

## 6. Change together (duplicated computations)

When you change one of these, grep for the others. Each group lists every copy found.

1. **Unit price of a cart line (stock → wholesale → discount → RAW→BASE)**
   - `cart_product_price` Helpers.php:1334 (the reference)
   - `cart_product_tax` Helpers.php:1411 (repeats the discount block to get the taxed base)
   - `CartUtility::get_price` + `discount_calculation` CartUtility.php:88/113 (writes `carts.price`)
   - `CartController@updateQuantity` CartController.php:303-347 (RAW, different order)
   - `HomeController@variant_price` HomeController.php:1876-1980
   - `Api/V2/ProductController@getPrice` 196-305
   - dead copies: `cart_product_discount` 1471, `carts_product_discount` 1514
2. **"Is the product discount running?"** (`discount_start_date == null || now in [start,end]`): every item in 1, plus `home_discounted_price` 1749, `home_discounted_base_price(_by_stock_id)` 1859/1899, `FakeDiscountService::payablePrice` 229. Flash deals and fake discounts both **write** these columns (FlashDealController 82-87/156-178/202-212, `ProductFlashDealService::store`, `FakeDiscountService::inflate/restore`, `ProductService` 38-42/213-217/413-417/590-611, `PartnerCatalogWriter`).
3. **Tax on a line**: `cart_product_price` 1387-1399, `cart_product_tax` 1451-1460, `CartUtility::tax_calculation` 138, `variant_price` 1956-1966, `getPrice` 283-293 (all on BASE) vs `home_price`/`home_discounted_price`/`home_base_price*`/`home_discounted_base_price*` (all on RAW, before conversion).
4. **Displayed product price**: `home_price`, `home_discounted_price`, `home_base_price`, `home_discounted_base_price`, the two `_by_stock_id` variants (POS `app/Http/Resources/PosProductCollection.php:18`), `discount_in_percentage`, and the resource collections listed in 4.1.
5. **Cart / checkout totals (subtotal + tax + shipping − discount)**:
   - `resources/views/frontend/partials/cart/cart_summary.blade.php` 316-442
   - `resources/views/frontend/partials/cart_summary.blade.php` (older copy, used by `payment_select.blade.php:661`)
   - `resources/views/frontend/partials/cart/cart_details.blade.php`, `cart/cart.blade.php`, `cart/addedToCart.blade.php`, `partials/cart_details.blade.php`
   - `CheckoutController@index` 423-450, `@store_delivery_info` 898-933, `@checkout` 503-513 (min order)
   - `Api/V2/CartController@summary` 54-78, `@getList` 164-258
   - `Api/V2/Seller/PosController` (POS cart), `app/Http/Resources/V2/CartCollection`, `Resources/V2/Seller/CartCollection`
6. **Order creation (freezing prices into `order_details`/`orders`)**: `OrderController@store` 509-742, `Api/V2/OrderController@store` 38-306 (a commented-out old copy follows at 307+), `PaymeraAppCheckoutUtility::buildSnapshot` 44 + `createOrders` 210. Shipping placement differs in all three (see 8.1).
7. **Coupon discount at apply time**: `CheckoutController@apply_coupon_code` 998-1042, `Api/V2/CheckoutController@apply_coupon_code` 97-251, dead `Api/V2/CouponController@apply` 15-145, dead `carts_coupon_discount` Helpers.php:1563. Cart-wide coupons are applied and re-judged only through `CouponUtility::applyCartWideCoupon` / `refreshCartCoupon` (every cart change, both `updateCartStatus`, the order paths); a new path that changes cart rows must call `refreshCartCoupon`.
8. **Coupon discount after an order edit**: `CombinedOrderCouponUtility::computeDiscount`/`welcomeBaseDiscount`/`scopedOrderDiscounts` must match item 7 (they currently differ on shipping, see 8.1).
9. **Coupon re-check at order time** (`rejectionReason` + `refreshCartCoupon` (minimum, covered products) + reset carts): `OrderController@store` 534-562, `Api/V2/OrderController@store` 60-96, `Api/V2/CheckoutController@createPayment` 523-559.
10. **Coupon listings**: `Api/V2/CouponController@couponList/couponList1` 181/187 (status, dates, `visible`, not governorates, platform, user), `get_coupons()` Helpers.php:4195 (no platform/user filter), `coupon_box.blade.php`, `checkout_coupon.blade.php`.
11. **Per-row shipping written to `carts.shipping_cost`**: `CheckoutController@index` 428-436, `@updateDeliveryAddress` ~1270, `@updateDeliveryInfo` ~1320-1335, `Api/V2/ShippingController@shipping_cost` 47-81, `Api/V2/AddressController` ~198-208, `cart_shipping_cost(..., persist=true)`. Readers: `cart_shipping_cost`, `carrier_base_price`, `CarrierCollection`, `CartCollection:23`, `PaymeraAppCheckoutUtility:69`.
12. **Free-shipping banner vs charge**: `free_shipping_progress` (web `CartController@addToCart` 214-228, web cart page `cart_free_shipping_bar()`, API `getList` 316-342, API `add` ~487-530) must stay in step with the `area_wise_shipping` branch of `getShippingCost` and `cart_shipping_base_total`. Pinned by `FreeShippingThresholdTest`.
13. **Order amount display** (must use the frozen rate): invoices, `backend/sales/*`, `seller/orders/show`, emails, WhatsApp/SMS templates, Paymera (`CheckoutController:578`, `Api/V2/CheckoutController:618`, `rePay` ~437). Current exceptions: see 8.4.
14. **RAW aggregates in SQL** must use `raw_to_base_sql`: `ReportController` (5 places), `SalesProfitReportExport`, `HomeController` listing filter, `product_profit_sql`. Missing in `SearchController` and the API product sort.
15. **Currency factor sources**: `currency_syp_factor`, `CompensationCouponUtility::sypFactor` (on purpose no 1.0 fallback), `Currency::orderFactors/marketFactors`, `BusinessSetting::booted`, `convert_to_usd_from_syp` (own 10-min cache), `SettingsCollection` (API settings exchange rate).
16. **Fake discount vs other writers of `unit_price/discount`**: `FakeDiscountService` is the only allowed writer for fake discounts, but the product edit (`ProductService` update), flash deals and the partner sync also write the same columns.
17. **The seller's share of a product discount** (`SellerDiscount`, since 2026-11-04 the seller pays for it): `SellerDiscount::unitCut()` repeats `cart_product_price`'s stock price → wholesale tier → running discount order, so a change to how the cart applies a discount must change it too. Who reads it: `SellerPricing::orderLineCost()` (the frozen purchase price), `SellerCurrency::stampLine/lineAmount/lineDiscount()` (the seller's currency), `product_cost_price()`, `product_profit_sql()` (in SQL, with its own fake-discount test) and the seller form's JS preview (`seller_discount_preview`).
18. **Seller writers of prices and discounts**: the seller product form (`Seller\ProductController@update` → `ProductService::update` + `ProductStockService::store` + `SellerPricing::afterSellerSave()`), the seller app (`Api/V2/Seller` product update, same path) and the prices page (`SellerPriceBoard::savePrices()` / `setDiscount()` / `stop()`, 2026-11-08). All three must: keep the seller's discount percentage as a percentage of their own price (`SellerDiscount::fromSellerPercent()`), let variants left alone follow a changed product price, never change a discount held by Kapitano's offers section (`SectionOffers::heldProductIds()`; the form does not check it yet) and never let a leftover fake discount become a real one. The page's JS copies the variant-follow rule (`seller/prices/partials/script`, `followVariants()`).
19. **Discount parts** (`DiscountParts`, ADR 0012, 2026-11-10): a product's discount is the seller's part + Kapitano's part, each with its own dates, in `product_discount_parts`; the product's `discount*` columns hold the total running now (`DiscountParts::columnsFor()` / `sync()`). Writers that must go through `DiscountParts::put()` (never the columns of a product with parts): the product forms (`ProductService::store/update` → `formInput()` / `fromForm()`, the admin form's `kapitano_*` fields from `backend/product/partials/discount_parts_fields`), the category-wise discount (`ProductService::setCategoryWiseDiscount`), the prices page (`SellerPriceBoard::setDiscount/stop`) and the offers section (`SectionOffers::putParts()`, `offer_row_id`). Kept in step by `Product::saved` → `DiscountParts::productSaved()` and the every-minute `discount-parts:roll`. The order line splits the customer's cut in `SellerDiscount::split()` (the seller's part on THEIR variant price, capped at the cut and the purchase price; Kapitano the rest, `order_details.kapitano_discount`, BASE; lines outside seller pricing: `SellerDiscount::stampKapitano()`); `product_profit_sql()` subtracts only Kapitano's part from our margin. A line with Kapitano's part books its real (negative) commission (`CommissionController::calculateCommission`, `SellerBalanceUtility`).

## 7. Settings (`business_settings`) that affect pricing

| Key | Effect |
|---|---|
| `system_default_currency` | currency id of BASE (cached 86400 s as `system_default_currency`); changing it closes a rate period |
| `symbol_format`, `decimal_separator`, `no_of_decimals` | `format_price` |
| `show_currency_switcher` | UI only; `currency_symbol()` always returns the SYP symbol and `get_system_currency()` forces the session to SYP |
| `shipping_type` | `flat_rate` / `seller_wise_shipping` / `area_wise_shipping` / `carrier_wise_shipping` / `product_wise_shipping`. Which one production uses is **(unverified)**; `cities.carttotal` and the free-shipping banners suggest `area_wise_shipping` |
| `flat_rate_shipping_cost`, `shipping_cost_admin` | BASE amounts for their modes |
| `pickup_point` | allows pickup rows (shipping 0) |
| `coupon_system` | shows or hides the coupon UI only |
| `minimum_order_amount_check`, `minimum_order_amount` | web checkout only; the app check is commented out |
| `vendor_commission_activation`, `seller_commission_type` (`fixed_rate`/`seller_based`/`category_based`), `vendor_commission`, `category_wise_commission` | **legacy**: only order lines without `order_details.purchase_price` (before seller pricing) and the helpers while `SellerPricing::ready()` is false. Seller products are priced by `app/Services/SellerPricing` (seller price → selling / purchase, see CLAUDE.md), and `product_cost_price`, `product_profit_sql`, `product_commission_percentage` read `purchase_price` against the true selling price |
| `club_point_convert_rate` | points → wallet BASE (`Api/V2/ClubpointController@convert_into_wallet`) |
| `guest_checkout_activation` | which carts count (temp user) |
| `seller_wholesale_product` | whether sellers may create wholesale tiers |
| Addons table (cached 86400 s as `addons`) | `club_point`, `affiliate_system`, `wholesale` etc. switch branches |

Non-setting config: `config('app.exchange_rate_history_path')`, `config('app.exchange_rate_log_path')`; `CompensationCouponUtility::AMOUNT_SYP = 200`, `VALID_DAYS = 30`.

## 8. Gotchas, known bugs, caching pitfalls

### 8.1 Totals that disagree with each other

- **Shipping is rounded differently per checkout.** Web `OrderController@store:737` stores `round_system_price(cart_shipping_cost($carts), 1000)` (nearest **1000 SYP**). The cart summary the customer saw uses unit 5 (`cart_summary.blade.php:358`), and so do the app COD path (`Api/V2/OrderController:225`) and the app Paymera path (`PaymeraAppCheckoutUtility:69`). `tests/Unit/PriceRoundingTest::test_shipping_uses_the_same_five_unit_rounding` describes the unit-5 intent. The web charge can therefore differ from the displayed total.
- **Paymera app orders count shipping twice in derived totals (likely bug, verify on data).** `buildSnapshot` puts shipping into each `orders.grand_total` and `order_details.shipping_cost`, and `createPayment:615` also sets `combined_orders.shipping_cost`. `combined_order_payable_total()` (1298) and `CombinedOrderCouponUtility::recalculate` (79-82; `allocate` already adds detail shipping at 434-437) both add `combined.shipping_cost` on top. The web path avoids this because details carry 0 and the shipping lives only on the combined order.
- **Coupon base with or without shipping.** Web/app apply adds (stored) shipping to the `cart_base`/welcome base. `CombinedOrderCouponUtility::eligibleAmount` (121) excludes it. So the first admin edit of an order can shrink a percent coupon, or drop it below `min_buy`.
- **Coupon spread over rows.** Web apply divides by **all** rows (CheckoutController:1038); the order sums only **active** rows (OrderController:629). A cart with unselected rows therefore gets less discount than was shown. The API divides by active rows but writes to all rows (Api/V2/CheckoutController:246-251).
- **`carts.price` is not a single form.** It is BASE after `CartUtility::get_price`, but web `updateQuantity:346` writes RAW, with the discount applied before the wholesale override, which is the reverse of `cart_product_price`. Readers of `carts.price`: the API welcome branch (Api/V2/CheckoutController:209, broken anyway), dead `Api/V2/CouponController@apply`, `backend/reports/cart_activity.blade.php:294`, cart-retargeting values **(unverified which column they read)**. New code should never trust `carts.price`; recompute with `cart_product_price`.
- `CheckoutController@index:440-446` and `@store_delivery_info:924-932` total shipping as `getShippingCost($carts, 0, ...)`, the first row only. That is right for area-wise but wrong for per-row modes (flat, seller, product). The visible summary uses `cart_shipping_cost`, so only `$total` (wallet-balance check, pixel `value`) is affected.
- The cart page zeroes `carts.shipping_cost` (CartController:82-87). A coupon applied on the cart page therefore sees shipping 0, and the same coupon applied on the checkout page sees real shipping.
- The app `getList` free-shipping banner uses the **default** address (318-326). `summary` and the order use the cart's `address_id`. With several addresses they can disagree.
- Rounding: product pages round once after adding tax in RAW (`home_*`). The cart rounds the price and the tax separately in BASE. With taxes configured, the product page and the cart can differ by up to one rounding unit. Tax `amount` is RAW in `home_*` and BASE in `cart_*`, which only matters for market products.

### 8.2 Outright bugs (file:line)

- `Api/V2/CheckoutController.php:105,188`: `$cartItem['shipping']` (no such column) → shipping is always 0 in the app coupon base, and it is multiplied by quantity.
- `Api/V2/CheckoutController.php:208-232` (`welcome_base`): `$couponDetails` is undefined (the variable is `$coupon_details`). Laravel turns the warning into an exception → 500. The branch also answers with the `success` key and never writes carts, so welcome coupons cannot be applied in the app.
- `Api/V2/CheckoutController.php:55,873`, `Api/V2/CartController.php:31,83,100`, `Api/V2/ShippingController.php:25,91`: they act on `$request->user_id` / `temp_user_id` rather than the authenticated user, and the cart/shipping routes have no `auth` middleware (routes/api.php:263-294). Anyone can read or modify another user's cart totals, coupon and shipping (IDOR).
- `Api/V2/CouponController.php:75`: uses `Address` without a `use` import (fatal). Dead code, because the route is commented out at api.php:571.
- `resources/views/frontend/product_details/details.blade.php:259`, `Http/Resources/V2/ProductWholesaleResource.php:23`, `Resources/V2/PosProductCollection.php:20`, `ClassifiedProductMiniCollection.php:21`, auction resources (`AuctionMiniCollection:23`, `AuctionBidProducts:41-42`): RAW passed to `single_price()`. The result is only wrong for `market` products while `real_rate > 0`.
- `SearchController.php:124-125,157-161,212-213,268-272` and `Api/V2/ProductController.php:530-534`: compare or sort RAW `unit_price` without `raw_to_base_sql`. It is also likely that the SYP slider bounds are compared with USD `unit_price` **(unverified what units the search form sends)**.
- `Http/Resources/V2/Seller/OrderDetailResource.php:30`: `format_price(round_system_price(...))` formats a BASE amount as SYP without converting it.
- (Gone since 2026-11-05: flash deals are switched off and `FlashDealController` redirects to the offers section, `App\Services\SectionOffers`, which refuses a fake-discounted product.) `FlashDealController` update (156-162) and destroy (202-209) reset `discount = 0` / `amount` on every product. On a fake-discounted product this leaves `unit_price` inflated with no discount, so the customer pays the inflated price until `FakeDiscountService::resync` runs (the admin "repair" button, `CouponController@discountRepair` 228). Creating a flash deal on a fake-discounted product replaces its percent and dates. When the deal expires, the inflated price is charged. `FakeDiscountService::inflate` clears the dates for exactly this reason (197-199).
- `calculateCommissionAffilationClubPoint()` (3208-3212) and `routes/club_points.php` reference `App\Http\Controllers\ClubPointController`, which does not exist in `app/`. Enabling the `club_point` addon would fatal on commission calculation. The web order store records `earn_point` (OrderController:659); the app does not. `Session::club_point` is subtracted in `cart_summary` but no web controller sets it or subtracts it at order time.
- `CouponRequest` requires `discount >= 1` (BASE, i.e. ≥ 1 USD). The compensation coupon (~1.45 USD) bypasses the form. Editing a compensation coupon through the form still works because 1.45 ≥ 1, but amounts under 1 USD are impossible to create from the admin.
- `offerUserWelcomeCoupon` does not copy `max_discount`, so `welcomeBaseDiscount`'s cap never applies. The web apply never caps welcome percent coupons either.
- `CheckoutController@checkout` Paymera amount uses `(int)` truncation of the SYP amount (578; also `Api/V2/CheckoutController:618`), not rounding.

### 8.3 Unverified or undecided semantics

- Is `product_taxes.tax` with `tax_type = amount` RAW or BASE? The code is inconsistent (8.1). Whether any product has taxes today is **(unverified)**.
- `products.shipping_cost` is treated as BASE (no product passed to `round_system_price` in `getShippingCost`), while other product money is RAW.
- The product discount `amount` is subtracted in RAW (correct under the contract).

### 8.4 Order amounts shown at today's rate (violates 4.6)

`backend/dashboard.blade.php:237,255,266,588` (`AdminController` 113-170 sums `grand_total` with no rate), `SellerOrderActionController.php:239`, `SellerWhatsAppUtility.php:655`, `backend/reports/cart_retargeting*.blade.php` (order_value), `CartRetargetController.php:143,477,487`, `backend/sales/show.blade.php:229` and `seller/orders/show.blade.php:152` (manual payment amount). `WhatsAppCampaignUtility.php:374` converts a coupon amount at today's rate, which is correct.

### 8.5 Caching pitfalls

- `system_default_currency` (86400 s) is never forgotten by code. Rate edits to the default currency or switching it need `php artisan cache:clear`.
- `get_syp_currency()` and `exchange_rate_history()` are per-request memos (request attributes). In queue workers or long-running CLI processes `request()` is a single shared instance, so they may keep the rate for the life of the process **(unverified whether any worker prices things)**.
- `addons` (86400 s), `business_settings` via `get_setting` (cached; key/TTL not checked here **(unverified)**), `currency_rates_syp_usd` (10 min).
- Product lists cached as models: `featured_products` (3600 s), `newest_products` (3600 s), `todays_deal_products` (forever), `products-category-{id}` (86400 s). They hold RAW product rows, so the SYP price re-renders with the current rate, but **discount and price edits stay stale** until forgotten. Only `FakeDiscountService::forgetCaches`, `ProductController:709` and `HomeSectionProductController:188-189` forget some of them. Flash-deal edits and ordinary product price edits do not forget `products-category-*` **(verify per path before relying on it)**.
- The rate-history file is the only record of past rates. Losing `storage/app/exchange_rate_history.json` silently re-prices every old order at today's rate.
- `raw_to_base_sql` bakes the market multiplier into the SQL string. Do not cache the SQL across a rate change.

### 8.6 Other things to know

- Cancelled orders: `order_is_cancelled` = `delivery_status == 'cancelled'`. They give their coupon back and count as 0 in `order_payable_total`.
- The coupon date check compares `strtotime(date('d-m-Y'))` (today at 00:00) with `start_date`/`end_date`. `end_date` comes from the admin date-range text, so whether the last day is inclusive depends on the time part the picker sends **(unverified)**.
- `CouponUtility` degrades on older schemas (`Schema::hasColumn/hasTable` on every call). Those checks run per request and are cheap-ish, but they are repeated for every cart row in loops.
- `getShippingCost` calls `Product::find` for every row on every call, so the cost is quadratic in the number of rows. `cart_shipping_cost` preloads `product`, but `getShippingCost` does not use the relation.

## 9. Tests covering this domain

Unit (`tests/Unit`): `CartPricingTest` (missing stock → 0; 5-unit rounding; round per unit before × qty), `PriceRoundingTest` (nearest 5; shipping also 5).

Feature (SQLite unless noted):
- `ExchangeTypePricingTest` (17): the RAW/BASE/SYP contract, market fallback, `raw_to_base_sql`.
- `OrderExchangeRateSnapshotTest` (14): history file, `order_rate_sql`, all order shapes, logging, default-currency switch, unwritable/corrupt files.
- `ExchangeTypeRuntimeTest` (6, **live MySQL**, opt-in `KAPITANO_RUNTIME_DB_TESTS=1`, rolled back).
- `FakeDiscountTest` (13), `FakeDiscountPermissionsMigrationTest` (3).
- `FreeShippingThresholdTest` (10), `AdminBulkAreaShippingCostTest` (6).
- `CouponSingleUseTest` (21), `CouponAllowedUsersTest` (15), `CouponProductScopeTest` (13), `CombinedOrderCouponTest` (20), `CompensationCouponTest` (7), `CouponUsageReportTest` (16), `AdminCouponAppPopupTest` (16).
- `CancelledOrderTotalsTest` (6) / `CancelledOrderTotalsRuntimeTest` (4, live).
- `SellerPriceBoardTest` (10): the seller's prices page (prices through the rule, variants following, own discount %, offers and fake discounts locked, stop keeps the discount, filters, tabs, endpoints).
- `PaymeraAppCheckoutTest` (13), `PaymeraRefundCancelsOrderTest` (4), `CartUnavailableVariantCheckoutTest` (10), `ProductProfitSortTest` (4), `PlatformMetricsServiceTest` (8), `AdminReportsRuntimeTest` (12, live), `DeletedOrderArchiveTest` (11).

Not covered by any test found: the web and API `apply_coupon_code` controllers end to end, `governorates_base`, `welcome_base` apply, `carrier_wise_shipping` / `seller_wise_shipping` / `flat_rate`, the 1000-unit shipping rounding in `OrderController@store`, flash deal ↔ fake discount interaction, `SearchController` price filter, wholesale tiers.

## 10. Open questions

1. Which `shipping_type` does production use, and is the 1000-SYP rounding of web shipping (OrderController:737) intentional? If so, the cart summary and the app should match it.
2. Are Paymera-app orders really showing shipping twice in the admin combined-order view and after coupon recalculation? Check a real combined order with `order_from = 'app'` and `payment_type = 'paymera'`.
3. Should the coupon base include shipping (checkout) or not (`CombinedOrderCouponUtility`)? One rule should win.
4. Should `governorates_base` exist on the web, and `welcome_base` in the app? Both are broken or missing today.
5. Is any product `market` with `real_rate > 0` in production? If not, most RAW-vs-BASE bugs in 8.2 are latent.
6. Do any products carry `product_taxes`, and is the `amount` tax meant as RAW or BASE?
7. Are seller-owned coupons used? They currently discount other sellers' items.
8. Is the `club_point` addon active? If yes, commission calculation fatals (missing controller).
9. Where do the columns `exchange_type`, `real_rate`, `real_price`, `products.discount_*` come from schema-wise, and what are their defaults (no migration found)?
10. Should the dashboard, seller order confirmation and WhatsApp alerts use `single_order_price` like the invoices?
