# 04 — Save Path & Lock Behaviour

## Two save paths today

Both write the snapshot via the same flow. New entry points must go through
the same shape (whitelist → dedupe → service → snapshot write → hash) — no
exceptions.

| Path | File | When |
|---|---|---|
| Vendor edit | `app/Http/Controllers/Web/VendorPortal/ListingController::update` | Vendor saves the listing edit form. |
| Onboarding store | `app/Http/Controllers/Web/Onboarding/ListingController::store` | First-save right after the onboarding wizard. |

## Vendor save flow (full sequence)

```
POST /admin/listings/{uuid}                  (vendor portal, auth + can:vendor)
        │
        ▼
ListingController::update($request, $uuid)
        │
        ├─ findOwnedListing($uuid)  ← scope: vendor must own it
        │
        ├─ validate non-pricing fields (subtitle, summary, RFQ, SEO, OG)
        ├─ apply non-pricing payload via $listing->update($payload)
        │   (these still save even on a locked listing)
        │
        ├─ if 'category_ids' submitted AND vendor_id present:
        │     │
        │     ├─ if isPricingLocked():
        │     │       skip everything below; flash warning to session('warnings')
        │     │
        │     └─ else:
        │           ├─ allowed = whitelistedCategoryIdsForListing($listing, $req->category_ids)
        │           │     ├─ ints, dedupe, > 0
        │           │     ├─ filter to TenantCategoryMap.is_enabled = true
        │           │     └─ force-include $listing->primary_category_id
        │           │
        │           ├─ VendorCategoryMap delete-and-recreate for $listing->vendor_id
        │           │     (rows include is_primary, source='vendor', confidence=1.0)
        │           │
        │           └─ writePricingSnapshot($listing, $allowed, $service)
        │                 ├─ defensive: re-checks isPricingLocked() — no-op if true
        │                 ├─ quote = service->quote($tenant, $allowed)
        │                 ├─ rule  = service->resolvePricingRule($tenant)
        │                 └─ forceFill + save:
        │                       pricing_currency
        │                       pricing_subtotal
        │                       pricing_snapshot_json
        │                       pricing_rule_snapshot_json
        │                       pricing_version
        │                       pricing_calculated_at = now()
        │                       pricing_hash = service->snapshotHash(quote)
        │
        └─ redirect to vendor.listings.show with status (and warnings if any)
```

## Why the whitelist matters

The `category_ids[]` form input is vendor-controlled. A vendor could (and
will eventually try to) submit category ids the tenant has not enabled.
The whitelist:

- **Drops** ids not in `sm_tenant_category_map (is_enabled=true)`.
- **Force-includes** `primary_category_id` so billing surface and
  rendering stay consistent — the primary is always part of the snapshot.
- **Dedupes** so duplicate submissions can't double-bill.

This is the only enforcement gate. The pricing service downstream **trusts
its inputs** — never write a save path that calls the service with raw,
unvalidated input.

## Onboarding store flow

Same shape, single category:

```
POST /vendors/onboarding/listing
        │
        ▼
Onboarding\ListingController::store($request, $context)
        │
        ├─ DB::transaction → create vendor + location + listing + plan subscription
        │
        └─ if (! $listing->isPricingLocked())
            quote = service->quote($tenant, [$listing->primary_category_id])
            rule  = service->resolvePricingRule($tenant)
            forceFill + save snapshot fields incl. pricing_hash
```

A brand-new listing is never locked, but the guard is there for symmetry.
If a future flow ever creates a pre-locked listing, it'll behave correctly
without changes here.

## Lock behaviour

### Locking

```php
$listing->lockPricing();              // pricing_locked_at = now()
$listing->lockPricing(now()->addDay()); // explicit timestamp
```

### Unlocking

```php
$listing->unlockPricing();            // pricing_locked_at = null
```

`unlockPricing()` does **not** recalculate. The listing keeps the snapshot
it had at lock time. The next authorised save on the now-unlocked listing
will recalculate and overwrite.

### What the save path skips on a locked listing

| Action | Locked behaviour |
|---|---|
| `subtitle`, `summary`, `body_content`, `video_url` | Still save normally |
| RFQ / contact_visible / SEO toggles | Still save normally |
| Meta title / description / keywords (paid plans) | Still save normally |
| OG image upload (paid plans) | Still save normally |
| **`category_ids[]` submission** | **Ignored** |
| **`VendorCategoryMap` rebuild** | **Skipped** |
| **`writePricingSnapshot()`** | **Short-circuited** |
| Flash to session('warnings') | "Pricing is locked on this listing — categories and pricing were not changed. Contact support to unlock." |

### Where lock enforcement lives

Three call sites — all named so future grep finds them:

1. `VendorPortal\ListingController::update()` — outer guard, surfaces the flash warning.
2. `VendorPortal\ListingController::writePricingSnapshot()` — defensive inner guard.
3. `Onboarding\ListingController::store()` — symmetry guard.

If you add a fourth save path, replicate the guard. Don't rely on a single
place catching it.

## Client-side manipulation

Fields like `pricing_subtotal`, `pricing_currency`, `total`, `subtotal` are
**not in any controller's validator**. Even if a vendor submits them, the
save path never reads them. The snapshot is built from the service's
return value alone.

Verified: a request with `pricing_subtotal=0.01` and `currency=XXX` saves
`$150.00` and `USD` (the actual computed values). The fake fields are
silently dropped.

## Hash regeneration

`pricing_hash` is recomputed only when `writePricingSnapshot()` runs to
completion. That means:

- Same-input save → same hash (deterministic via `recursiveKsort + sha256`).
- Different categories or different rule → different hash.
- Locked save → no rewrite → hash unchanged.
- Tenant rule edit (without a save) → no rewrite → hash unchanged.
