# Checkout promo "no change" — diagnosis, fix & live stage verification

**Date:** 2026-08-18 · **Reporter:** Mohammed Adel (mobile) · **Env verified:** `stageapi.navagoo.com`

---

## ملخص (بالعربي)

الموبايل كان بيبعت `resolve_promotions=1` عند الـ checkout والرد بيرجع `promotions: []` (مفيش خصم = "مفيش تغيير").
**السبب:** بورتال الصالون كان بيعمل **عروض كود بس** (`trigger_type` الافتراضي = CODE، ومفيش خيار Automatic في الفورم).
عرض الكود بيظهر في تاب الديلز لكنه **مبيتطبّقش أوتوماتيك** — لازم العميل يكتب الكود. فالطلب من غير `promo_code` بيرجّع قايمة فاضية.

**الحل:** ضفنا خيار **«Automatic deal / Code»** في فورم عروض البورتال، فبقى الصالون يقدر يعمل عرض بيتطبّق تلقائيًا (يظهر في `promotions[]` من غير كود، زي الديمو).

**اتأكدنا بنفسنا على ستيج** (بالتوكن اللي المستخدم بعته): أول ما العرض بقى `automatic`، الرد رجّع العرض متطبّق (خصم 26.91).

---

## 1. Reported symptom

Mobile request (Flutter log, 2026-08-18 22:56, `https://stageapi.navagoo.com/booking/preparing-booking?lang=ar`):

```json
Request:  { "resolve_promotions": 1, "services_ids": [77] }
Response: { "success": true, "data": {
    "amount": 234.0, "vat": 35.1, "total_paid": 269.1,
    "discount_value": 0, "promotions": [], "applied_promotion_id": null } }
```

`promotions: []` → no deal auto-applies → totals unchanged → the salon's deal appears to "do nothing".

## 2. Root cause

`api/models/BookingForm::resolveAndApplyPromotions()` (the `resolve_promotions=1` resolver) only
surfaces **AUTOMATIC** promotions when no `promo_code` is typed (a CODE promo is only added when
its code is entered — by design, mirrors the demo). That part is correct.

The real gap was upstream, in **authoring**: the shop portal promo form
(`frontend/views/promo-code/_form.php` + `PromoCodeController`) never set `trigger_type`, and the
model defaults it to `TRIGGER_CODE` (`common/models/PromoCode.php` → `[['trigger_type'],'default',
'value'=>self::TRIGGER_CODE]`). **So every portal-authored deal was a CODE deal.** A code deal is
listed in the Deals tab (the feed does not filter by trigger) but never auto-applies at checkout —
exactly the "shows in the app but no discount" symptom.

## 3. Fix — portal can now author AUTOMATIC deals (portal-only, no API contract change)

Added an **"Automatic deal / Code"** segmented toggle to the promo form, mirroring the existing
"All services / Specific services" pattern. Automatic hides the Code field and drops its
`required`; the controller applies the choice server-side (same IDOR-safe shape as the
service-scope handling).

| Layer | File | Change |
|---|---|---|
| View | `frontend/views/promo-code/_form.php` | "Deal type" segmented toggle (`promo_trigger_mode`), hides Code field when automatic; new-record default = Code |
| Controller | `frontend/controllers/PromoCodeController.php` | `applyTriggerType()` → sets `trigger_type` (AUTOMATIC clears `code`); called in create + update |
| Model | `common/models/PromoCode.php` | code `required` `whenClient` now gated on the toggle (required only in Code mode) |
| i18n | `common/messages/{ar,en}/frontend.php` | `Deal type`, `Automatic deal`, `Code`, helper line |

The API already reads `trigger_type` (W-P1/W-P2), so no `api/` change was needed here.

### Local browser verification (dev, shop-owner login)
- Toggle renders; buttons `[Automatic deal, Code]`; picking **Automatic** hides the Code field; picking **Code** restores it.
- New-record default = **Code** (fresh load), code field visible.
- Created an automatic deal end-to-end via the real form (12%, **no code**, active) → saved (promo #942, code empty). A CODE deal cannot save without a code, so saving code-less proves `trigger_type=AUTOMATIC`.

## 4. Live STAGE verification (self-checked with the user-supplied token)

`POST https://stageapi.navagoo.com/booking/preparing-booking?lang=ar`
`Authorization: Bearer Bt_… (user-supplied, staging)` · body `resolve_promotions=1&services_ids[]=77`

```json
"data": {
  "amount": 234.0, "total_paid": 242.19, "discount_value": 26.91,
  "applied_promotion_id": 18,
  "promotions": [{
    "id": 18, "title_en": "Welcome to the Summer",
    "trigger": "automatic", "code": null,
    "discount_type": "percentage", "discount_value": 10,
    "discount_amount": 26.91, "is_best": true, "is_applied": true,
    "shop_id": 7,
    "shop": { "shop_id": 7, "shop_name": "NAC ", "image": "https://storagenavago…jpg" },
    "applicable_services": null
  }]
}
```

**Result:** deal #18 is now `trigger: automatic` → it appears in `promotions[]` and **applies**
(discount 26.91, `is_applied:true`, `total_paid` reduced). When the same deal was a CODE deal
(the 22:56 mobile log) the list was empty. This is the diagnosis, confirmed on the real server.

Bonus confirmations from the same stage response:
- `promotions[].shop` object is present → the `applicable_services` + `shop` parity fix (commit `ba2e712`) is live on stage.
- `applicable_services: null` here = the deal applies to **all** services (it is not service-scoped).

## 5. Action items

- **Salon owners:** to make a deal auto-apply, create/edit it in the portal with **Deal type = Automatic deal** (Code deals still require the customer to type the code). Requires deploying the portal change in §3 to stage.
- **Mobile:** no change required — keep sending `resolve_promotions=1`. Automatic deals now arrive in `promotions[]` (best-first, with `applicable_services` + `shop`). For CODE deals, send the typed `promo_code`.
- **Note:** deal #18 shows `discount_value: 10` (10%) in the API while the app card rendered "خصم 50%" — the 50% badge on the service image is a **service-level** discount, separate from the promo. Worth confirming the app isn't conflating the two on the deal card.
