# MuveOne Pricer — How a Price Is Calculated (End to End)

> Business-readable guide to the MoPricer quote calculation. For engineering
> internals see `MO_PRICER_ARCHITECTURE.md` (pipeline/code map) and
> `app/Pricing/MuveOnePricingKernel.php` (the authoritative math).
> Every number in this guide was verified against the live engine on
> 2026-07-11 — including the worked example, reproduced to the penny.
> Updated 2026-07-13 for the **seasonal adjustment** feature (§5): quotes now
> carry a moving date / collection window, and a monthly percentage curve can
> scale the route + ferry portion of the price. Updated 2026-07-14 for the
> **weekly layer** (§5): each rule may also carry 52 week-of-year percentages
> that override its monthly values for the ISO weeks they cover.

---

## 1. The pipeline in one picture

```
From / To addresses + volume (m³)
        │
        ▼
① ROUTE   Google Directions, shaped by your WAYPOINTS (minus EXCEPTIONS)
        │      → total km, km per country, detected FERRY crossings
        ▼
② ZONES   route line tested against your zone polygons → zone charges
        │
        ▼
③ ENGINE  per-country PRICING DATA + distance tier
        │      per-km cost + markup + ferry + minimum + zones
        │      → then margin OR VAT
        ▼
④ PRICE   rounded to whole £/€  (+ CUSTOMS returned separately, API only)
```

**Two inputs shown on the form do NOT change the price:** the weight/mass
field is never used in any calculation (it is echoed back only), and the
formula picker was retired — the web page **and the API** always price with
the canonical MuveOne formula (the API rejects a `formula` field with 422
since the 2026-07-13 seasonal-pricing hard cut).

The engine step ③ also applies the **seasonal adjustment** (§5): the quote's
moving date / collection window selects a month (and an ISO week, when the
matched rule has weekly points), and that period's stored percentage scales
the route + ferry portion of the price.

---

## 2. What each admin screen actually does

### Waypoints (`/waypoint/create`) — shape the route, cost nothing directly
A waypoint forces the Google route through a place, matched by exact
origin/destination address or by from/to country. Live examples: every move
from or to the United Kingdom goes via **Dover** and **Calais**; Greece moves
go via **Kulata** (BG) and **Calafat** (RO). Effect on price is indirect:
the forced route changes the **distance** (which drives the per-km cost) and
deliberately drags the route through charging **zones** (e.g. Dover Ferry).
The screenshot's "Waypoint 2" = Dover + Calais were applied.

### Exceptions (`/exception/create`) — remove a waypoint for a country pair
The counterweight to waypoints: "do NOT use waypoint X when going from
country A to country B". Live examples: Dover/Calais are excluded for
Ireland↔Ireland moves; Hamburg is excluded for intra-Scandinavia moves.
No direct cost either — they only stop a route being forced somewhere.

### Ferries (`/ferry/create`) — priced crossings detected on the route
Each row is a crossing (matched by coordinates on the Google route) with a
price. All detected crossings on a route are summed into the ferry total —
**but the first crossing priced 0 stops the summing** and flags the quote
`has_null_ferries` (the operator should price that crossing). Any crossing
Google returns that isn't in the table yet is auto-inserted at price 0.
The ferry total is then **scaled per size** by the `ferry_cost_percentage`
from the pricing data (e.g. a 5 m³ short-haul to France pays only 70% of the
ferry price; 15 m³ pays 100%). The Dover–Calais crossing is currently priced
0 — Channel cost is charged through the Dover Ferry *zone* instead (below).

### Zones (`/zone/create`) — geographic surcharges
A zone is a polygon with up to **three independent pricing rules**, keyed on
how the route relates to the polygon (added 2026-07-14):

| Rule | Fires when |
|---|---|
| **Starts in** | the route's first point (collection) is inside the polygon |
| **Ends in** | the route's last point (delivery) is inside the polygon |
| **Passes through** | the route enters the polygon but neither endpoint is inside |

Each rule has its own enabled flag, price and mode:

- **fixed** → flat price (live: Dover Ferry £119, Northern Ireland £550,
  Alonissos/Corfu £400);
- **not fixed** → `price × km inside the zone` — and that distance is the
  **straight line** between the first and last route point inside the
  polygon, not driven km (live per-km zones: Scotland, W France, E UK…).

Resolution rules:

- *Passes through* is strictly transit — it never fires when the route starts
  or ends inside the zone (most-specific-wins).
- A route that both **starts and ends** in the same zone charges only the
  **Starts in** rule (*Ends in* applies instead when *Starts in* is disabled or
  unpriced). Chosen deliberately so intra-zone moves kept their pre-feature price.
- A disabled rule (or one with no price) charges nothing — so a zone can
  target, say, only journeys originating in it.

Every zone existing before the feature was backfilled with **all three rules
enabled at its legacy price/mode**, which reproduces the old "charges whenever
the route enters the polygon" behaviour exactly — no live quote changed at
rollout (golden-verified). The `/map` breakdown now labels each zone charge
with the rule that fired ("route starts in this zone", …).

Quirk worth knowing: the engine has a special branch that would treat a zone
named exactly `dover_ferry` as **ferry** cost (scaled by the ferry
percentage). The live zone is named `"Dover Ferry"`, which does **not**
match, so its £119 is always charged as a **flat zone cost** at 100%. That
is the preserved, golden-tested behaviour.

### Pricing Data (`/formula/pricing-data`) — the heart of the math
A per-country rate table (JSON). Which country's rates apply:

| From | To | Rates used |
|---|---|---|
| supported-EU | supported-EU | the `EU` entry |
| anywhere | GB | the **origin** country's entry |
| anywhere | anywhere else | the **destination** country's entry |
| (country not in the table) | | the `ZZ` default entry (+ a warning log) |

"Supported EU" = EU-27 minus Ireland, Malta, Cyprus. Each country entry has
`total_markup` plus, per size (5/10/15/20 m³) and per distance tier
(`<1000` / `<2000` / `≤3000` / above, km): `ppk_cost` (per-km rate),
`ppk_percentage`, `markup_percentage`, `ferry_cost_percentage`.
Edits on this page change quotes immediately. (The 20 m³ blocks were seeded
2026-07-17 from the 15 m³ blocks under the owner's +20% rule — `ppk_percentage`,
`ferry_cost_percentage` and `markup_percentage` ×1.2, per-km `ppk_cost`
unchanged — and are tunable per country here like any other size.)

### Custom Pricing (`/customs_price/create`) — customs clearance ("cc")
Looked up for cross-border moves involving a customs country (e.g. UK after
Brexit): exact origin+destination row, else origin-only / destination-only
row, else the default row (currently price 300 / cost 100). `price` is the
**selling** price, `cost` is the **cost** price. Two important facts:
it is returned **only on the API** (`custom_clearance: {price, cost}`), and
it is **never added into `total_price`** — the caller must add it. The web
`/map` page does not display it at all.

### Seasonality (`/seasonality`) — month- and week-of-year demand curves
A rule is an origin→destination country pair (either side may be "any"), with
12 draggable month percentages and, optionally, **52 draggable week
percentages** (both capped **−50% … +200%**). The matched period's percent
scales **route cost + ferry cost only** (markup, minimum and zone cost are
never adjusted), inside the subtotal — so it flows through margin/VAT like
any other cost. The period comes from the quote's **moving date / collection
window** (default: today, UK time). Within a rule, a **weekly value beats the
monthly value** for the ISO weeks it covers; the monthly value is the
fallback. The global default rule (`*→*`) ships at 0% for all 12 months, is
undeletable (its weekly curve, like any weekly curve, may be deleted), and
while every curve is 0% — and whenever no weekly rows exist — prices are
identical to pre-weekly quotes. Saving a curve writes an audit log row.
Full detail in §5.

---

## 3. The price formula (per requested size)

```
route cost   = ppk_cost × total_km × ppk_percentage
markup       = total_markup × markup_percentage + 100        (the +100 is fixed)
ferry cost   = (sum of priced crossings) × ferry_cost_percentage
minimum      = size minimum, only when total_km < 500
               (5 m³ £127.29 · 10 m³ £169.72 · 15 m³ £169.72 · 20 m³ £203.66 = 15 m³ × 1.2)
zone cost    = sum of zone charges (fixed, or price × km-in-zone)
seasonal     = (route cost + ferry cost) × period %          (week % if set, else month % — §5)

subtotal     = route cost + markup + ferry cost + minimum + zone cost + seasonal
```

Then exactly one of two adjustments, decided by **place of supply** — the
country bucket (UK / NO / CH / EU) with the most route kilometres, with a
Northern-Ireland override (origin in NI → UK, destination in NI → EU):

- place of supply **UK** → **+20% VAT**;
- place of supply **EU** and one end is GB → **± the country margin** from
  the pricing data (from GB: destination's `import_margin`; to GB: origin's
  `export_margin`).

```
total = subtotal × (1 + margin) × (1 + VAT)      (one of the two is always 0)
price shown = total rounded to whole units
```

**30 m³ is not priced from the table** — it is simply **2 × the 15 m³
result** (which doubles the minimum, zone cost, the flat +100 — and the
seasonal amount too).

---

## 4. Worked example — the live quote, verified

City of London → Paris, 300 kg (ignored), 5 m³. Route via Dover→Calais
(waypoints) = **499 km**, France rates (destination), low-distance tier,
Dover–Calais ferry priced 0, Dover Ferry zone hit, place of supply EU
(more km in France than UK), GB origin → FR `import_margin` 5%.

| Line | Calculation | Amount |
|---|---|---:|
| Route cost | 0.86 × 499 km × 0.65 | 278.94 |
| Markup | 580 × 0.6 + 100 | 448.00 |
| Ferry cost | 0 × 0.7 (crossing priced 0 → `has_null_ferries`) | 0.00 |
| Minimum | 499 km < 500 km → 5 m³ minimum | 127.29 |
| Zone cost | Dover Ferry, fixed | 119.00 |
| Seasonal adjustment | (278.94 + 0.00) × 0% (all curves at 0 today) | 0.00 |
| Subtotal | | 973.23 |
| Import margin | × 1.05 (GB → FR, place of supply EU) | 1021.89 |
| VAT | none (place of supply not UK) | — |
| **Total** | rounded | **1022** ✓ |

**The same quote with a seasonal curve set** — say the GB→FR (or global)
curve had July at **+10%** and the quote's moving date falls in July: the
seasonal line becomes `(278.94 + 0.00) × 10% = 27.89`, the subtotal
`973.23 + 27.89 = 1001.13`, margin `× 1.05` → `1051.18` → **total 1051**.
Only the route + ferry portion is scaled — markup, minimum and zone cost are
untouched — and the +£29 lands *before* the margin, so the margin compounds
on it (D1 in the seasonal plan). *(Verified against the shipped engine:
re-running the live quote with the default curve temporarily at +10% moved
the 15 m³ seasonal amount to 47.23 and the 30 m³ display amount to exactly
2 × that = 94.46, with the breakdown panel still reconciling.)*

Other scenario shapes:

- **EU → EU** (e.g. Paris → Berlin): `EU` rates, no margin, no VAT.
- **EU → GB** (import): origin country's rates and `export_margin`; VAT only
  if most kilometres are on UK roads (rare on long EU legs).
- **UK domestic**: place of supply UK → +20% VAT; under 500 km the minimum
  applies; Scotland/E UK/W UK per-km zones may add cost.
- **Route not in the rate table** → `ZZ` default rates and a warning log.

---

## 5. Seasonal adjustment (month- and week-of-year pricing)

Added 2026-07-13 (monthly), extended 2026-07-14 (weekly layer). A stored
**percentage curve** scales **M = route cost + ferry cost** (the two
per-distance lines of §3) before the subtotal is formed, so the adjustment
flows through margin/VAT naturally. Every rule carries a 12-point monthly
curve and may additionally carry a 52-point **weekly** curve that overrides
it for the ISO weeks it covers. Everything below was verified against the
shipped code.

### Which date decides the month (and week)

Every quote (API and `/map`) now carries a date basis:

- **`moving_date`** (`DD/MM/YYYY`) → that date's month — and its **ISO
  week** (ISO-8601, Monday-start; the same numbering as
  `Carbon::isoWeek`).
- **`collection_window`** (`{from, to}`, both `DD/MM/YYYY`) → every month
  **and every ISO week** the window touches; the **highest** percent across
  all candidate periods wins. The window width is now **per size**
  (`config/transfer.php`): 5 m³ → up to 4 days, 10 m³ → 3, 15 m³ → 2,
  20 & 30 m³ → **exact day** (`from == to`); a multi-size request is capped at
  the most restrictive (smallest) window. So a window spans at most 4 calendar
  days — at most 2 months and 2 ISO weeks, including Dec→Jan across a year
  boundary.
- **Neither** → **today in Europe/London**. A no-touch `/map` quote prices
  for today; the form's date field is pre-filled with today's date.

Validation (rejected with a clear 422 message): supplying *both* fields;
non-strict `DD/MM/YYYY` (e.g. `2026-08-15`, `1/8/2026`, or the impossible
`31/02/2027`); a date in the past (today, UK time, is the earliest valid
date); `to` before `from`; a window wider than the selected size's cap
(4/3/2/1/1 days for 5/10/15/20/30 m³ — an exact-day size requires
`from == to`). There is **no upper
horizon**: curves are perpetual month-of-year values, so July 2027 uses the
same July percent as July 2026.

### Which curve applies

Rules live in the `seasonal_adjustments` table — one percent per rule per
period, where a period is a month (`period_type = 'month'`, key 1–12) or an
ISO week (`period_type = 'week'`, key 1–52) — and are matched
**most-specific first** (mirroring the customs fallback chain). The
country-pair specificity is **primary**; the week/month layering happens
**inside** each tier:

1. exact origin→destination pair (e.g. `GB→IT`) — its **week** value if one
   is stored for the resolved week, else its **month** value
2. origin-only (`GB→*`) — week, else month
3. destination-only (`*→IT`) — week, else month
4. the global default (`*→*`) — week, else month
5. nothing matched at all → hard 0%

So a weekly point never jumps the pair chain: a specific pair's **monthly**
rule still beats a **global weekly** default — the week only wins against
the month **of the same rule tier**. **Week 53** (the extra ISO week some
years have, e.g. dates around New Year) uses the stored **week-52** value —
the editor exposes 52 weekly points. For a collection window the engine
takes the **MAX percent across every period the window touches** (each
week and each month resolving through the chain above), exactly the
monthly D10 semantics.

Direction matters: GB→PL and PL→GB are separate rules. Country codes come
from the same origin/destination codes the engine already derives (the
engine's non-ISO `UK` is normalized to `GB` for this lookup only — on both
layers). Percents are capped at **−50% … +200%** on both write (editor
rejects) and read (stored out-of-range values are clamped), months and
weeks alike. **With zero weekly rows in the database, prices are
byte-identical to the monthly-only feature.**

### What it does to the price

Per size: `seasonal = (route cost + ferry cost) × percent / 100`, added into
the subtotal **before margin/VAT** (see §3/§4). Markup, minimum and zone
cost are never adjusted. The 30 m³ price is still 2 × the 15 m³ result, so
its seasonal amount is exactly 2 × the 15 m³ amount. With every curve at 0%
(the shipped default) prices are byte-identical to pre-feature quotes.

### The editor (`/seasonality`)

Reached from the **Seasonality** card on `/home` or the Setup menu. Pick or
create a rule — origin/destination chosen from **country dropdowns** (GB +
the European service area; "any country" = wildcard), so invalid codes
cannot be entered — then **drag the 12 monthly points** (or, on the weekly
layer, the **52 weekly points**) on the chart and save; saves send
`period_type` (`month`, the default, or `week`) with a matching 12- or
52-value payload. Saves are gated by the `SEASONALITY_UPDATE` env flag
(same posture as Pricing Data) and write an audit log row. The `*→*`
default rule's **monthly** curve cannot be deleted; deleting a **weekly**
curve (allowed on any rule, including `*→*`) simply removes the weekly
override and lets the monthly values apply again.

### On the API

`POST /api/quote_request` accepts `moving_date` **or** `collection_window`
(see above; the old `formula` field is now **rejected** — every quote prices
with the canonical MuveOne formula). The response gains:

```json
"seasonal_adjustment": {
  "basis": "moving_date",          // or "collection_window" | "default_today"
  "dates": ["15/08/2026"],
  "month_used": 8,
  "week_used": 33,                 // ISO week, when a WEEKLY value decided; null when a month/default did
  "percent": 15,
  "amount": {"5": 41.84, "10": …, "15": …, "30": …},
  "rule": "default"                // week-decided rules gain a suffix, e.g. "GB→IT (week 33)"; month-decided keep the legacy labels ("GB→IT", "default")
}
```

The `/map` quote response carries the same block, and the breakdown panel
shows a per-size seasonal row (rendered even at 0%) between the zone line
and the Subtotal: month-decided quotes read exactly as before —
"Seasonal adjustment (Jul, +0%)" — and week-decided quotes read
"Seasonal adjustment (week 33 (10–16 Aug), +15%)", the date span being that
ISO week of the first resolved date's year.

### Weekly worked example

Take the §4 quote (City of London → Paris, 5 m³ — route cost 278.94, ferry
0.00, subtotal 973.23 at 0%) with a moving date of **15/08/2026** (ISO week
33, Mon 10 – Sun 16 Aug), and the global `*→*` rule holding **August =
+10%** on its monthly curve and **week 33 = +15%** on its weekly curve:

- Both periods resolve through the pair chain to the same `*→*` tier, where
  the **week value beats the month value** → **+15%**.
- `seasonal = (278.94 + 0.00) × 15% = 41.84`; subtotal
  `973.23 + 41.84 = 1015.07`; import margin `× 1.05` → **total 1066**.
- Delete the weekly curve (or move the date to a week with no weekly
  point): the same quote falls back to the **August monthly +10%** →
  seasonal 27.89 → **total 1051**, exactly the §4 monthly walkthrough.
- If instead a specific `GB→FR` rule existed with August at +5% (monthly
  only), that **pair monthly** value (+5%) would win over the **global
  weekly** +15% — the pair chain stays primary.

---

## 6. Reading a quote on `/map`

- **Moving date / Collection window** = the date basis for the seasonal
  adjustment (§5); defaults to today, UK time.
- **Ferry Cost** = the summed crossing prices (before the percentage).
- **Waypoint** = how many forced stops shaped the route.
- **Zones** = how many zone polygons the route entered (open for prices).
- **Total Price** = the first requested size's final price.
- **How was this price calculated?** = the per-line breakdown above —
  including the seasonal row — live for the quote you just ran (added
  2026-07-11; the panel self-verifies against the served price and hides
  rather than show wrong numbers).

## 7. Where the hardcoded numbers live

The fixed values in §3 (the +100 markup base, the minimum-charge table and
its 500 km gate, VAT 0.2, the ×2 rule, the distance tiers, the supported-EU
list, 0-dp rounding) live in the `pricing_constants` store and are edited at
**`/constants`** (the **Pricing Constants** card on `/home`) — the editable-
constants program completed on 2026-07-12. The seasonal curves of §5 live in
the `seasonal_adjustments` table and are edited at **`/seasonality`**.
