# 07 — Edge Cases

Every weird input the system has been tested against and the documented
behaviour. New edge cases discovered later go here, not in scattered
comments.

## Category-input edge cases

### Duplicate category ids

**Input:** `[plumbing_id, plumbing_id, plumbing_id]`
**Behaviour:** Deduped to a single line before `quote()` runs.
**Where:** `ListingCategoryPricingService::quote()` step 1 — `array_unique` on the int-cast input.

### Unknown / invalid category id

**Input:** `[99999]`
**Behaviour:** `Category::whereIn()` returns no row, the id is silently dropped, the line never appears in the quote. `lines = []`, `subtotal = 0`, `category_count = 0`.
**Why silent:** Defensive — we don't expose internal id existence to the caller. The vendor save path additionally enforces a tenant-enabled whitelist before the service ever sees the id.

### Empty input

**Input:** `[]`
**Behaviour:** `quote()` returns the normalized empty shape — every key present, all monetary values `0.00`, `lines = []`, `category_count = 0`.

### Mixed Category models + ints

**Input:** `[Category $a, 7, $b]`
**Behaviour:** Coerced to ints (`Category` model id extracted), then deduped + filtered.

## Price-resolution edge cases

### Zero-price category

**Setup:** `categories.base_price = 0.00`
**Behaviour:** Selectable. Line appears in the quote with `base_price = 0.00` and `line_total = 0.00`. `source = "category_base"`.

### Null-price category

**Setup:** Both `tenant_category_map.price_override` and `categories.base_price` are NULL.
**Behaviour:** Treated as 0. Line appears with `base_price = 0.00`, `line_total = 0.00`, `source = "unset"`. Used as a marker so callers can distinguish "explicitly free" from "never priced".

### Tenant override

**Setup:** `tenant_category_map.price_override = 400.00`, `categories.base_price = 200.00`
**Behaviour:** Override wins. Line shows `base_price = 400.00`, `source = "tenant_override"`. Crucially, this is the value that drives the slot ordering (highest-first), so an override on a low-base category can promote it to slot 1.

## Tenant-rule edge cases

### Missing tenant pricing config

**Setup:** `settings_json.category_pricing` doesn't exist, or `tenant` is `null`.
**Behaviour:** Defaults to `tiered_percent` 100/75/50. The service builds a 3-tier ladder from the `DEFAULT_*_PCT` constants.

### Out-of-range percentages

**Setup:** `first_percent = 500`, `second_percent = -25`, `third_plus_percent = 50`
**Behaviour:** Clamped to `[0, 100]` → applied as 100 / 0 / 50. Clamping happens in `resolvePricingRule()` — every consumer reads through that one method, so no clamp is bypassed.

### Mixed legacy + new shape

**Setup:** `tiers[]` AND `first_percent` both present.
**Behaviour:** `tiers[]` wins. Legacy keys are mirrored on save for back-compat readers but the service prefers the array form when both exist.

### Tier with `slot: 0` or non-positive

**Behaviour:** Silently dropped during normalisation in `resolvePricingRule()`.

### Beyond the highest tier

**Setup:** `tiers = [{slot:1,percent:100},{slot:2,percent:50}]` and the listing has 5 categories.
**Behaviour:** Slots 3, 4, 5 all inherit the **highest defined slot's percent** (50% in this example). Documented in `percentForSlot()`.

## Currency edge cases

| Tenant `settings.currency` | Resolved currency |
|---|---|
| unset | `USD` |
| `"USD"` | `USD` |
| `"eur"` (lowercase) | `EUR` (uppercased) |
| `"INVALID"` (4 letters) | `USD` (regex rejects) |
| `"$$"` (non-letters) | `USD` |
| `123` (non-string) | `USD` |

Resolution in `resolveCurrency()`. Stored in every snapshot and every
`/pricing/preview` response.

## Sorting / determinism edge cases

### Same effective price ties

**Setup:** Three categories all at $150 with category ids 2, 3, 4.
**Behaviour:** Sort by effective price DESC; on ties, sort by `category_id` ASC. Slots assigned: id 2 → slot 1, id 3 → slot 2, id 4 → slot 3. Stable across requests + reorderings.

### Submission order

**Setup:** Vendor submits `[hvac, plumbing, electrical]` (intentionally not sorted by price).
**Behaviour:** Internal sort runs unconditionally. Output slot order is highest-effective-price-first regardless of the input array order.

## Save-path edge cases

### Vendor submits non-tenant-enabled category id

**Setup:** Category id 99999 in the form, not in `tenant_category_map.is_enabled`.
**Behaviour:** Whitelist drops it. Snapshot computed from the remaining valid ids only. No error returned to the vendor — the malformed id simply never reaches the snapshot.

### Vendor submits client-side `pricing_subtotal=0.01`

**Behaviour:** The validator doesn't accept those keys — they're not in any controller's rules. The save path computes its own subtotal via `quote()`. Verified: `pricing_subtotal` lands at the actual computed value, not `0.01`.

### Vendor submits `currency=XXX`

**Behaviour:** Same as above. The snapshot's `currency` comes from `resolveCurrency($tenant)`, never from the request.

### Locked listing — vendor changes categories

**Behaviour:** `category_ids[]` ignored. `VendorCategoryMap` rows untouched. Snapshot + hash + `pricing_calculated_at` unchanged. Flash warning surfaces in `session('warnings')`.

### Locked listing — vendor changes non-pricing fields (subtitle, summary, etc.)

**Behaviour:** Saved normally. Lock only freezes the pricing surface.

## Rounding edge cases

| Input | Output (`money()`) |
|---|---|
| 10.005 | 10.01 |
| 123.456 | 123.46 |
| 0.005 | 0.01 |
| 99.995 | 100.00 |
| 2.345 | 2.35 |
| 2.355 | 2.36 |

`PHP_ROUND_HALF_UP` consistently. Note: `DECIMAL(10,2)` columns themselves
truncate at 2 places on insert, so storing `10.005` directly via the model
saves `10.00` (the DB strips the third decimal **before** any service
rounding can happen). When testing rounding, call `money()` directly or
use a path where the fractional cents arise from the **percent
multiplication**, not the stored base value.

## Snapshot edge cases

### Reading a listing that pre-dates pricing

**Setup:** Old listing row with `pricing_snapshot_json = NULL`.
**Behaviour:** `Listing::pricingSnapshot()` returns `null`. `pricingLines()` returns `[]`. `pricingCategoryCount()` returns `0`. `pricingHash()` returns `null`. Safe to call all helpers — they're null-safe.

### Tenant changes the rule after a listing was saved

**Behaviour:** Saved snapshot is unchanged. The listing's `pricing_subtotal`, `pricing_snapshot_json`, `pricing_rule_snapshot_json`, `pricing_hash`, `pricing_calculated_at` all stay frozen at their save-time values. A fresh `quote()` would return a different number — but no code path applies that fresh quote to the saved listing automatically.

### Hash mismatch detection (manual)

```php
$listing = Listing::find($id);
$expected = app(ListingCategoryPricingService::class)
    ->snapshotHash($listing->pricingSnapshot());
$tampered = $expected !== $listing->pricingHash();
```

If `$tampered` is true the snapshot JSON has been edited directly in the DB
since the last authorised save. No automated check yet; `10-future-work.md`
flags it as a candidate for an admin debug surface.
