# 06 — `POST /pricing/preview`

Read-only pricing preview endpoint. Single source of truth for any UI that
needs to show "what would this listing cost?" without actually saving.
Used today by the vendor portal multi-cat picker; designed for future
checkout previews and admin tools as well.

## Route

```
POST /pricing/preview      name: pricing.preview
```

Defined in `routes/web.php`. Controller:
`App\Http\Controllers\Web\PricingPreviewController`.

## Tenancy + auth

- **Tenant** is resolved via the standard `TenantContext` middleware (host
  → `sm_domains` lookup). No tenant id in the URL.
- **No role gate** — both vendor portal and tenant-admin UIs need to call
  it without role gymnastics. Read-only price disclosure is intentional.
- **CSRF protected** — same-origin POST with the `_token` (or
  `X-XSRF-TOKEN` header) is required. Cross-origin / unauthenticated POSTs
  are rejected by the standard web middleware group.

## Request

`Content-Type: application/x-www-form-urlencoded` (or multipart). Common
headers from the JS caller:

```
X-Requested-With: XMLHttpRequest
Accept: application/json
X-XSRF-TOKEN: <decoded XSRF cookie value>
```

### Body

| Field | Type | Required | Notes |
|---|---|---|---|
| `category_ids[]` | int[] | optional (max 20) | Each must be ≥ 1. Unknown ids silently produce empty quotes. |

Validation rules (server-side):

```php
'category_ids'   => ['nullable', 'array', 'max:20'],
'category_ids.*' => ['integer', 'min:1'],
```

## Response

`HTTP 200 application/json`. Shape:

```jsonc
{
  "quote": {                                  // ← service->quote() output
    "tenant_id": 2,
    "currency": "USD",
    "version": "tiered-v1",
    "pricing_mode": "tiered_percent",
    "percents": { "first": 100, "second": 75, "third_plus": 50 },
    "category_count": 3,
    "lines": [
      { "category_id": 1, "name": "Home Services",
        "base_price": 300.00, "source": "tenant_override",
        "slot": 1, "percent_applied": 100, "line_total": 300.00 },
      // ...
    ],
    "pre_discount_subtotal": 650.00,
    "subtotal": 525.00
  },
  "rule": {                                   // ← service->resolvePricingRule()
    "mode": "tiered_percent",
    "first": 100, "second": 75, "third_plus": 50,
    "tiers": [
      { "slot": 1, "percent": 100 },
      { "slot": 2, "percent": 75  },
      { "slot": 3, "percent": 50  }
    ]
  },
  "upsell": "Add another category for 25% off.",   // ← service->upsellHint()
  "count":  3                                       // ← convenience
}
```

### Empty / invalid input

- No `category_ids[]` → `quote.lines = []`, `subtotal = 0`, `category_count = 0`.
- All ids invalid (e.g. `[99999]`) → same as empty.
- Duplicates in `category_ids[]` → silently deduped.

## Verified scenarios

| Input | Subtotal | Notes |
|---|---|---|
| `[plumbing]` ($150) | $150.00 | upsell: "Add another category for 25% off." |
| `[plumbing, electrical]` (both $150) | $262.50 | "Add more categories for 50% off each." |
| `[plumbing, electrical, hvac]` | $337.50 | same |
| `[plumbing, plumbing, plumbing]` | $150.00 | deduped to one line |
| `[99999]` | $0.00, count=0 | invalid id silently dropped |
| `[]` | $0.00 | empty quote with normalized shape |
| `[hvac, plumbing, electrical]` (mixed 300/200/150, unsorted) | $525.00 | sorting is by effective price DESC, not request order |
| Tenant override on slot-1 cat → $400 | $625.00 | override wins; slot 1 = $400 × 100% |
| Zero-price category included | included with line_total = 0.00 | |
| Null-price category included | included with line_total = 0.00, `source: unset` | |
| Out-of-range tenant percentages (500, -25, 50) | applied as 100, 0, 50 | clamped |
| `currency=eur` setting | response `currency: "EUR"` | uppercased |

## Single source of truth

`PricingPreviewController` does no math itself — it calls
`ListingCategoryPricingService::quote()` and returns the result.
Save-time enforcement does **the same thing**: vendor save calls
`quote()` with the whitelisted ids and persists the result. So preview
totals and save totals are guaranteed identical for the same input.

(This invariant was specifically tested — see
`08-validation-results.md`, scenario J.)

## Don't add new fields casually

If you need new data on the response, add it to the **service** so all
consumers benefit + the snapshot stored on listings reflects it too.
Don't append controller-only fields — that's how the snapshot and the
preview drift.
