# MuveOne Pricer — Architecture & How It Works

> End-to-end documentation of the MuveOne Pricer application: a
> European logistics/removals pricing engine built on Laravel 11.

> ⚠️ **Partially stale.** The engine-specific sections below were corrected
> when the legacy EuropeExpress/VanOne engines were retired
> (`feat/drop-legacy-engines`), but the rest of this document predates recent
> work (PlaceKit autocomplete, seasonal pricing, zone-relationship pricing,
> brand restyle) and is due a full refresh in the docs phase (Phase 5).
> For current pricing behaviour see `docs/PRICING_GUIDE.md`.

---

## 1. What this application is

MuveOne Pricer calculates shipping/removal quotes for moving goods between
European locations. The data model is per-account (each user owns their own
waypoints, ferries, zones, etc.), but **MuveOne is the only pricing engine**:
the legacy EuropeExpress and VanOne engines were retired
(`feat/drop-legacy-engines`), and every account prices through the MuveOne
code path.

A quote is produced by combining:

1. **Driving route & distance** between two addresses (via the Google Maps
   Directions API).
2. **Per-country distance** breakdown (how many km are driven in each country).
3. **Ferry crossings** detected on the route, priced from the account's ferry table.
4. **Pricing zones** — customer-drawn geographic polygons with fixed or per-km fees.
5. **Customs clearance fees** for cross-border moves.
6. The **MuveOne pricing kernel** (`app/Pricing/MuveOnePricingKernel.php`) —
   versioned code, parameterized by the editable constants store — which ties
   all of the above together into a final price per shipment volume.

There are two ways to get a quote:

- **Web UI** (`POST /send_location`) — an internal dashboard with a live map.
- **External API** (`POST /api/quote_request`) — authenticated by an API key,
  intended for third-party integrations.

### Tech stack

| Layer        | Technology |
|--------------|------------|
| Framework    | Laravel 11, PHP 8.2 |
| Database     | MySQL (`onemovee_pricer_dev2`), utf8mb4 |
| Frontend     | Server-rendered Blade + jQuery + Axios + Materialize CSS; Yajra DataTables |
| Maps         | Google Maps Directions / Geocoding API + Google Maps JS API |
| Autocomplete | Algolia Places.js |
| Mail         | SMTP (Mailtrap sandbox in dev) |
| Build        | Laravel Mix (Webpack) |
| Hosting      | cPanel shared hosting; manual git-pull deploys from GitLab |

> The frontend scaffolds Vue 2 but does not use it — the UI is a traditional
> server-rendered app with jQuery/AJAX, not an SPA.

---

## 2. Domain model

`users` is the hub of the data model. Every pricing input cascades from a user
(`ON DELETE CASCADE`), giving each tenant an isolated configuration.

```
users (tenant account)
├── 1:1  maps           route-avoidance preferences (ferries / highway / tolls)
├── 1:N  waypoints      forced routing stops (e.g. Dover → Calais)
├── 1:N  exceptions     per-route waypoint exclusions
├── 1:N  ferries        ferry crossings + coordinates + price
├── 1:N  zones          geographic pricing polygons (GeoJSON file URL)
├── 1:N  customs_prices customs/clearance fees by origin→destination country
├── 1:N  formulas       kernel-routing rows (id/name → output variant; PHP body inert)
└── 1:N  logs           audit trail

Reference tables (not tenant-scoped):
    locations   29 supported countries + "requires customs" flag
    cities      ~1,665-row geocoding cache (Google-normalized names)
```

### Table reference

| Table            | Purpose | Key columns |
|------------------|---------|-------------|
| `users`          | Tenant account & auth | `email`, `password`, `APP_KEY` (API key) |
| `maps`           | Routing prefs (1 per user) | `ferries`, `highway`, `tolls` (booleans = *avoid*) |
| `waypoints`      | Forced routing stops | `origin`, `destination`, `waypoint`, `fcountry`, `tcountry`, `type` |
| `exceptions`     | Waypoint exclusions per route | `exception`, `countryf`, `countryt` |
| `ferries`        | Ferry crossings | `ferry`, `lat`/`lng`, `start_lat`/`start_lng`, `price` |
| `zones`          | Geographic pricing areas | `zone`, `price`, `country_code`, `polygon` (file URL), `fixed` |
| `customs_prices` | Clearance fees | `origin`, `destination`, `price`, `cost`, `isDefault` |
| `formulas`       | Kernel-variant routing rows (the stored PHP body is inert — never executed) | `formula_name`, `formula` (unused), `formula_encode` |
| `locations`      | Supported countries | `location`, `upload` (requires-customs flag) |
| `cities`         | Geocoding cache | `en` (normalized uppercase name) |
| `logs`           | Audit log | `log`, `html`, `user_id` |

Account type is resolved by **hard-coded email address** in
[`app/User.php`](app/User.php) `accountType()`:

```php
logistics@muveone.co.uk → UserType::MUVEONE
admin@muveone.co.uk     → UserType::MUVEONE
(anything else)         → throws Exception
```

The default **throw** for unlisted emails is load-bearing: several call sites
treat "accountType() threw" as "not a MuveOne account". Only MuveOne remains
as an account type since the legacy EuropeExpress/VanOne engines were retired.

---

## 3. Request lifecycle — how a quote is calculated

This is the core of the application. Both entry points converge on the same
calculation logic in `Map` and the repositories.

### Entry points

| Path | Auth | Handler |
|------|------|---------|
| `POST /api/quote_request` | `apikey` + `cityRestrict` middleware | `ApiController::getQuote` → `QuoteRepository::getQuote` |
| `POST /send_location` (web) | session `auth` | `MapController::getLocationDistance` |

The API request is validated in
[`app/Http/Controllers/ApiController.php`](app/Http/Controllers/ApiController.php):
`from`, `to`, `mass` (numeric), `volume` (array of 5/10/15/30). A
client-supplied `formula` is **rejected** (422) — the server pins the canonical
MuveOne formula id for both entry points.

### The pipeline

```
POST /api/quote_request  { from, to, mass, volume }
        │
        ▼
[1] QuoteRepository::getQuote()
        │
        ▼
[2] Map::makeApiRequest()  ─────────────►  Google Maps Directions API
        │                                    · origin, destination
        │                                    · user waypoints (optimize:true)
        │                                    · avoid=ferries|highways|tolls (from maps row)
        │   · detects new ferries (Ferry::detectFerry)
        │   · saves raw response to public/google_responses/{date}.json
        │   · on ZERO_RESULTS → Address::formatAddress() + one retry
        ▼
[3] Map::getDistance()          total km, duration, origin/dest coords, ferries
[4] Map::distanceByCounty()     per-country km, parsed from "Entering {country}"
[5] Zone::detectZones()         point-in-polygon vs user polygons → km-in-zone
[6] Map::calCulateData()        assembles distance + zones + ferries + country codes
        │
        ▼
[7] CustomPriceRepository::getClearancePrice()   customs fee for origin→dest
        │
        ▼
[8] Map::calcFinalPrice()       validates the formula row, then dispatches to
        │                        PricingDispatcher → MuveOnePricingKernel  ◄── pricing happens here
        ▼
[9] merge kernel output + custom_clearance (+ seasonal_adjustment)  →  JSON response
        │
        ▼
[10] Log::report()              audit entry written to logs table
```

### Step detail

**[2] Routing** — [`app/Map.php`](app/Map.php) `makeApiRequest()`.
Builds the Directions API call from the request plus the user's waypoints
(`Waypoint::UserWayPoint`) and avoidance flags (`maps` row). Waypoints let a
tenant force a route — e.g. always cross via Dover→Calais — and `exceptions`
remove specific waypoints for specific country pairs. Ferries present in the
response are detected and, if unseen before, inserted into `ferries` at £0
(pending manual pricing).

**[3][4] Distance analysis** — `getDistance()` sums leg distances; ferry legs
(`maneuver == 'ferry'`) are matched to the tenant's `ferries` table by
coordinates. `distanceByCounty()` walks the route steps and detects border
crossings from Google's *"Entering {country}"* HTML instruction text, producing
a `{ "GB": 150, "FR": 200, ... }` map (km per country).

**[5] Zones** — [`app/Zone.php`](app/Zone.php) `detectZones()`.
Loads the tenant's zones, reads each polygon's GeoJSON file from
`public/zones/`, and uses `GeometryLibrary\PolyUtil::containsLocation()` to test
whether the route passes through the polygon. For matched zones it computes the
distance travelled *inside* the zone. A zone is either `fixed` (flat fee) or
per-km (`price × km-in-zone`).

**[7] Customs** — [`app/Repositories/CustomPriceRepository.php`](app/Repositories/CustomPriceRepository.php)
`getClearancePrice()`. Applies only for cross-border moves. Matching order:
exact `origin`+`destination` country pair → partial (origin-only or
destination-only) → the tenant's `isDefault` row. Country names are normalized
via `City::localize()` (Google Geocode, cached in `cities`).

**[8] Pricing** — `Map::calcFinalPrice()` → `PricingDispatcher` (see next section).

### The response

JSON containing (roughly): `from`, `to`, `mass`, `volume`, `distance`,
`ferriesCost` + `ferries[]`, `zones[]`, the formula's `prices` (one per volume)
and `total_price`, plus `custom_clearance: { price, cost }`, and the Google
`routes[]` (used by the web map to draw the polyline).

---

## 4. The pricing engine

Pricing is **versioned PHP code** in `app/Pricing/`. The historical eval()
path — tenant-authored PHP stored in `formulas.formula` and executed with
`eval('?>' . $code . '<?php;')` — was retired (`feat/retire-eval-path`), after
the formula-authoring editor was removed. `formulas` rows survive only so
their id / `formula_name` can route a quote to a kernel output variant.

### How a price is computed

`Map::calcFinalPrice()` validates the formula row (`Formula::find` → 422 if
missing) and hands off to `App\Pricing\PricingDispatcher`:

1. **`MuveOneInputAssembler`** marshals the route data into a typed input,
   deliberately reproducing the legacy eval-string semantics (JSON round-trip,
   `{`/`}`/`:` character mangling, the 4-key country filter of
   `storage/app/muveone_pricing.json`, and the distance-tier ladder
   `low`/`mid`/`high`/`above_high`).
2. **`MuveOnePricingKernel::compute()`** does the math — EU-vs-UK routing, VAT
   place of supply, import/export margins, separate prices per volume tier —
   parameterized by the 8 editable constants in the `pricing_constants` DB
   store (`PricingConstantsProvider`; editable at `/constants`).
3. The pinned formula id selects an **output variant** via
   `PricingDispatcher::MUVEONE_FORMULAS` (5 → canonical, 11 → canonical,
   12 → extended).
4. **Seasonal adjustments** (`SeasonalContext`) scale the route + ferry portion
   when a moving date / collection window is supplied; the web UI's per-quote
   breakdown panel is produced by `PricingExplainer`.

Kernel inputs (assembled from the pipeline):

| Input | Meaning |
|----------|---------|
| mass | shipment weight (kg) |
| volume | array of volume options (5/10/15/30 m³) |
| distance | total route distance (km) |
| distance_by_country | `{ "GB": 150, "FR": 200 }` |
| from / to, country codes | address strings + ISO country codes |
| ferries | `[{ name, price }, …]` |
| zones | `[{ zone, price, fixed, distance }, …]` |
| pricing data + distance tier | rate JSON filtered to the relevant countries, plus a distance tier (`low`/`mid`/`high`/`above_high`) |

The per-country rate JSON lives in an editable file managed through the
**Pricing Data** page (`/formula/pricing-data`). The kernel returns an array
like `['price' => …, 'cost' => …, 'prices' => [...], 'total_price' => …]`;
any kernel failure is caught and returned as a plain-text
`price calculation failed` 400 response.

---

## 5. HTTP surface

### Web routes (`routes/web.php`) — session `auth`

| Method | Path | Action | Purpose |
|--------|------|--------|---------|
| GET | `/` | `HomeController@welcome` | Landing page (public) |
| GET | `/home` | `HomeController@index` | Dashboard |
| GET | `/map` | `MapController@getMapView` | Quote calculator UI |
| POST | `/send_location` | `MapController@getLocationDistance` | Calculate a quote |
| POST | `/updateMap` | `MapController@UpdateMapSettings` | Toggle avoid ferries/highway/tolls |
| GET/POST/PUT/DELETE | `/waypoint` | `WaypointController` | Waypoint CRUD |
| GET/POST/PUT/DELETE | `/ferry` | `FerryController` | Ferry CRUD |
| GET/POST/PUT/DELETE | `/zone` | `ZoneController` | Zone CRUD |
| GET/POST/PUT/DELETE | `/exception` | `ExceptionController` | Exception CRUD |
| GET/POST/PUT/DELETE | `/customs_price` | `CustomsPriceController` | Customs price CRUD |
| GET | `/formula/pricing-data` | `FormulaController@viewPricingData` | View pricing JSON |
| POST | `/formula/pricing-data-update` | `FormulaController@savePricingData` | Save pricing JSON |
| GET | `/logs` | closure → `logs` view | Log viewer (⚠️ no auth middleware) |
| — | `Auth::routes()` | — | Login / register / password reset |

### API routes (`routes/api.php`)

| Method | Path | Middleware | Action |
|--------|------|------------|--------|
| POST | `/api/quote_request` | `apikey`, `cityRestrict` | `ApiController@getQuote` |

### Middleware

| Alias | Class | Role |
|-------|-------|------|
| `auth` | `Authenticate` | Session auth for the web UI |
| `apikey` | `AuthKey` | Reads `App-Key` header, matches `users.APP_KEY`, sets the user |
| `cityRestrict` | `CityRestrict` | Blocks quotes for cities in `public/json/localization.json` (returns a **zeroed 200**, not an error) |
| `log-read` | `LogMiddleware` | Restricts log-reader API to emails in `LOG_READ_FOR` |
| `guest` | `RedirectIfAuthenticated` | Standard |

---

## 6. Frontend

Server-rendered Blade templates + jQuery/Axios; each management page is a form
plus a Yajra DataTable that CRUDs via AJAX. Layout is
[`resources/views/layouts/app.blade.php`](resources/views/layouts/app.blade.php)
(Materialize navbar, AJAX loader, a Settings modal exposing the user's `APP_KEY`
and the avoid-ferries/highway/tolls toggles).

| Page | Route | What it does |
|------|-------|--------------|
| Dashboard | `/home` | Card grid linking to every feature |
| **Map** | `/map` | Quote calculator: From/To (Algolia autocomplete), mass, volume, formula → `POST /send_location`; draws the route polyline + zone overlays with Google Maps JS |
| Waypoint | `/waypoint` | Manage forced routing stops |
| Ferry | `/ferry` | Manage ferry crossings + prices |
| **Zone** | `/zone` | Draw pricing polygons on Google Maps (Drawing Manager) or upload GeoJSON |
| Pricing Data | `/formula/pricing-data` | JSON table editor for per-country rates (gated by `PRICING_DATA_UPDATE`; emails a change notification) |
| Custom Pricing | `/customs_price` | Customs fees per origin→destination |
| Post Code Charges | `/post_code_charges` | UK CCZ/ULEZ + vehicle-type surcharges |
| Exception | `/exception` | Per-route waypoint exclusions |
| Logs | `/logs` | Log viewer (haruncpi/laravel-log-reader) |

Email templates live in `resources/views/emails/` — `changeNotificationMail`
notifies `CHANGE_NOTIFIER_EMAIL` (with IP + user + time) when pricing/formulas change.

---

## 7. Configuration & operations

- **Auth**: two guards — `web` (session, for the dashboard) and a custom
  `APP_KEY` header scheme for the API. Keys are 18-char random strings generated
  at registration and stored in plaintext; there is no expiry/rotation.
- **External services**: Google Maps (routing, geocoding, JS map, zone drawing),
  Algolia Places (autocomplete), SMTP mail. Keys live in `.env`.
- **Queues / jobs**: `sync` driver — no workers. The change-notification mail
  implements `ShouldQueue` but runs synchronously, so mail/API failures can block
  a request. No scheduled tasks are defined.
- **Tests**: `phpunit.xml` defines four suites — `Unit`, `Feature`
  (RefreshDatabase, SQLite in-memory), `GoldenMaster` (byte-exact quote
  snapshots), and `Pricing`. The old `tests/Formula` suite (per-account
  EuropeExpress/MuveOne/VanOne formula tests) was deleted along with the legacy
  engines.
- **Deployment**: cPanel shared hosting, manual `git pull` from
  `gitlab.com:muveone/devpricer`; no CI/CD, no queue supervisor, no documented
  backups. `.htaccess` carries cPanel PHP handlers.
- **Environment**: this checkout is dev/staging (`APP_DEBUG=true`, Mailtrap SMTP).

### Local development access

Running locally at **http://127.0.0.1:8000** (Laravel dev server) against the
imported `onemovee_pricer_dev2.sql` dump on a local MySQL instance.

Dashboard login (MuveOne account):

| Email | Password |
|-------|----------|
| `logistics@muveone.co.uk` | `bokvoq-4haqqo-Jottyw` |

> ⚠️ These are local-only development credentials. Do **not** commit this section
> to a shared repository or reuse the password anywhere else.

---

## 8. Known issues & risks

Found during a read-through — documented here so they aren't rediscovered later.
None are fixed by this document.

### Critical
1. ~~**Arbitrary code execution via `eval()`**~~ — **resolved**: the formula
   editor was removed and the eval() execution path itself was retired
   (`feat/retire-eval-path`). Pricing is versioned code in `app/Pricing/`;
   no tenant-authored PHP is ever executed.
2. **Committed secrets** — `.env` in the repo contains a live Google API key,
   Algolia keys, DB password, and Mailtrap creds; Google Maps JS keys are also
   hard-coded in `map.blade.php` and `zone.blade.php`. Rotate all of these and
   purge them from git history.

### Functional bugs
3. `MapController::UpdateMapSettings()` does `switch ($request)` on the request
   object rather than a property — the avoid-ferries/highway/tolls update logic
   is broken.
4. `Ferry::isValidate()` returns `$validated->errors()` before the `fails()`
   check, so its validation is inverted / dead.
5. `/logs` route has no `auth`/`log-read` middleware.
6. `CityRestrict` returns a zeroed `200` for restricted cities, so a client
   can't tell a restricted city from a genuinely free route.

### Design / hygiene
7. **No authorization layer** — no Policies/Gates; ownership rests on implicit
   `user_id` query scoping, and all Form Requests `authorize(){ return true; }`.
   Some controllers use `$request->all()` mass assignment.
8. Account type is a hard-coded email→type map instead of a role/flag column.
9. Dead code: `/test`, `test.blade.php`, `test2.blade.php`, `toll.blade.php`,
   `MapController::data()`, the typo'd `ceratePHPFormula()`, unused Vue setup.
10. **Fragile external parsing** — ferry names scraped from Google's `<b>` HTML,
    country detection from *"Entering X"* text, JSON→PHP-array via `str_replace`.
    All break silently if Google changes its response formatting.

---

## 9. Glossary

| Term | Meaning |
|------|---------|
| **Waypoint** | A forced intermediate stop that shapes the Google route (e.g. a specific ferry port). |
| **Exception** | A rule that removes a waypoint for a specific country pair. |
| **Zone** | A customer-drawn polygon with a fixed or per-km surcharge for routes passing through it. |
| **Formula** | A `formulas` row whose id/name routes a quote to a kernel output variant (canonical/extended); the stored PHP body is inert — pricing logic lives in `app/Pricing/`. |
| **Pricing Data** | Per-country rate JSON (`storage/app/muveone_pricing.json`) filtered into the pricing kernel's input. |
| **Customs price** | Border clearance fee applied to cross-border moves. |
| **Account type** | MuveOne (the only remaining type) — legacy EuropeExpress/VanOne engines are retired; unlisted emails throw. |
