# M4 Movers Quote System — Cursor Onboarding & Context

Paste this whole file into Cursor (as the first message in a new chat, or save it
as `CURSOR_CONTEXT.md` in the project root and tell Cursor to read it). It gives
Cursor everything it needs to continue development without re-explaining the codebase.

---

## 1. What this project is

A staff-facing **quoting system for M4 Movers Ltd**, a removals company operating
in the Thames Valley / M4 corridor, based in Slough, UK. Booking staff use it to
generate a price for a house move, print a clean branded PDF quote for the
customer, and save/retrieve/edit quotes.

It is built as a **Laravel 11 + MySQL** application, running locally on XAMPP for
now. It will later be deployed to a proper host (likely AWS, since the owner runs
other Laravel/Octane production systems).

**Design philosophy that must be preserved:** the staff quote screen is
deliberately *simple* — four plain-English steps ending in a big price and one
"Save & generate PDF" button. All pricing complexity lives server-side in a single
`PricingService`. Staff never see formulas or type a margin per quote. Do not
push pricing logic into the frontend, and do not add complexity to the staff
screen without a strong reason.

---

## 2. Tech stack & key decisions

- **Laravel 11** (new-style `bootstrap/app.php`, no Kernel.php).
- **MySQL** via Eloquent. Database name `m4movers`.
- **Blade** views (no Vue/React/Inertia). Vanilla JS in the Blade files for the
  live-price interactivity — kept intentionally dependency-light.
- **barryvdh/laravel-dompdf** for the customer PDF (`resources/views/pdf/quote.blade.php`).
- **Auth:** database users, session guard. Login is username + password
  (seeded `admin` / `m4mover$` — must be changed). Guests are redirected to `/login`.
- **Google Maps:** address autocomplete runs client-side (Places); the driving-
  distance calculation is **proxied server-side** (`POST /maps/distance`) so the
  Distance Matrix call keeps the key server-side. Key is in `.env` as
  `GOOGLE_MAPS_API_KEY`, country restriction `GOOGLE_MAPS_AUTOCOMPLETE_COUNTRY=gb`.
- **Settings** are stored in a `settings` table (key/value), cached, and merged
  over `Setting::DEFAULTS`. Everything tunable — rates, costs, margin, local
  radius, surcharges, base lat/long — lives there. The margin (`marginPct`) is a
  setting, applied to every quote automatically.

---

## 3. The pricing model (single source of truth)

All pricing lives in `app/Services/PricingService.php`. It is pure/testable and
has a passing PHPUnit test (`tests/Unit/PricingServiceTest.php`) that ports 30+
verified cases. **If you change pricing, update the test in the same commit.**

The formula:

```
COST BASIS = Labour + Mileage + Running-cost (overhead) recovery + Materials + Surcharges
PRICE      = Cost basis x (1 + marginPct/100)
             x (1 + weekendPct/100)   if weekend
             x (1 - offpeakPct/100)   if off-peak
FINAL      = round up to nearest £5, floored at minCharge
```

- **Labour**: hourly (`crewRate x hours`, min `minHours`), OR fixed house band
  (`HOUSE[size]` sets crew + hours), OR per-item (`ITEMS[type].price`, 15% bundle
  discount at `bundleThreshold`+ items).
- **Mileage**: £0 if the job is **local** (both pickup AND drop-off within
  `localMiles` of Slough centre). Otherwise `(totalMiles - localMiles*2) * distRate`.
- **Overhead recovery**: `dailyOverhead() * (hours / workDay)`, where
  `dailyOverhead = (vanFinance+insurance+git+ads+phone) / bookedDays`.
- **Materials**: `boxes * boxCharge`.
- **Surcharges**: stairs (`stairsRate` per floor, summed across pickup + drop-off),
  dismantle (`dismRate * dismItems`), same-day (`samedayFee`), ULEZ (`ulezFee`).
- **Internal cost/profit** (staff-only, never on the customer PDF): driver
  day-rate (pro-rated under `driverFullDay` hours), helpers (`helperHr * hours`
  per extra crew), fuel (`fuelMi * totalMiles`), overhead, materials cost.

`Setting::DEFAULTS` holds every default value with comments — read it first.

---

## 4. Directory map (the important files)

```
app/
  Services/PricingService.php        <- ALL pricing maths + HOUSE/ITEMS catalogues
  Models/
    Setting.php                      <- DEFAULTS + asArray()/saveMany(), cached
    Quote.php                        <- nextRef() = M4M-YYYYMMDD-0001 (DB counter, locked)
    User.php
  Http/Controllers/
    QuoteController.php              <- index/edit/price/store/list/destroy/pdf/distance
    SettingController.php            <- edit/update/reset
    AuthController.php               <- login/logout
resources/views/
  layouts/app.blade.php             <- topbar + toast + csrf
  auth/login.blade.php
  quotes/
    index.blade.php                 <- THE staff quote screen (4 steps + live price)
    list.blade.php                  <- saved quotes table (search/edit/pdf/delete)
    settings.blade.php              <- all editable rates/costs incl. margin
  pdf/quote.blade.php               <- customer PDF (NO costs/margin shown)
routes/web.php                       <- all routes, auth-guarded
database/migrations/                 <- users/sessions, quotes+counters, settings, cache
database/seeders/DatabaseSeeder.php  <- admin user + default settings
tests/Unit/PricingServiceTest.php    <- pricing test suite
public/assets/logo.png, favicon.png
```

Route names you'll reference: `quotes.index`, `quotes.edit`, `quotes.price`,
`quotes.store`, `quotes.list`, `quotes.pdf`, `quotes.destroy`, `maps.distance`,
`settings.edit`, `settings.update`, `settings.reset`, `login`, `logout`.

---

## 5. How data flows through a quote

1. Staff fill the 4-step form in `quotes/index.blade.php`.
2. On every change, JS POSTs the inputs to `quotes.price`; the controller runs
   `PricingService::calc()` and returns the full breakdown as JSON; the panel
   re-renders. **The server is the source of truth for price** — the JS only
   displays it and builds the plain-English "how it's worked out" modal.
3. Addresses use Google Places autocomplete; when both are set, JS POSTs coords to
   `maps.distance`, which calls Distance Matrix server-side and returns
   `totalMiles` + `isLocal`.
4. Save POSTs to `quotes.store`. New quotes get a `ref` from `Quote::nextRef()`;
   editing an existing `ref` updates it. `inputs` and `result` are stored as JSON.
5. PDF is `quotes.pdf` — renders `pdf/quote.blade.php` via dompdf, **customer-safe**
   (no cost/margin/profit anywhere).

---

## 6. Conventions to follow

- Keep the staff screen simple; put complexity in the service layer.
- Any new priceable factor: add its setting(s) to `Setting::DEFAULTS`, implement it
  in `PricingService::calc()`, expose it in `settings.blade.php`, wire the input in
  `quotes/index.blade.php`, include it in the PDF **only if the customer should see
  it**, and add a test case.
- Never show internal cost, overhead, or margin on the customer PDF.
- Money rounds up to the nearest £5; never below `minCharge`.
- Currency is GBP; locale en-GB; timezone Europe/London.
- Prefer plain Blade + vanilla JS over adding a frontend framework.
- When you change pricing, update `tests/Unit/PricingServiceTest.php` and keep it green.

---

## 7. FIRST TASK — add "Parking & carry distance" to the pricing

This is a required, high-priority feature the business needs. Staff must always ask
where the van can park and how far that is from the door, because a long carry adds
real labour. It is currently documented in the staff training PDF but **not yet in
the code** — implement it to match that model.

Implement it as a **carry-distance surcharge assessed at BOTH pickup and drop-off**
(exactly like stairs), in bands:

| Van-to-door distance        | Band     | Suggested charge (new setting) |
|-----------------------------|----------|--------------------------------|
| 0–10 m (driveway / outside) | none     | £0                             |
| 10–25 m (short carry)       | light    | `carryLight`  (default £10)    |
| 25–50 m (medium carry)      | standard | `carryStandard` (default £20)  |
| 50 m+ (long carry)          | heavy    | `carryHeavy`  (default £35)    |

Plus a **"no parking / permit / paid bay" add-on** per end (new setting
`parkingRestrictedFee`, default £15) to cover permit cost / fine risk / extra walk.

Requirements:
1. Add settings `carryLight`, `carryStandard`, `carryHeavy`, `parkingRestrictedFee`
   to `Setting::DEFAULTS` (with brief comments).
2. Extend `PricingService::calc()` to accept, per end (pickup + drop-off):
   a carry band (`none|light|standard|heavy`) and a boolean `restricted`. Sum both
   ends into the surcharge total. Include the parking amount in the returned
   breakdown so the info modal and internal panel can show it.
3. Add a **Step 2b "Parking & access"** block to `quotes/index.blade.php` under the
   addresses: for each end, a band selector (4 buttons or a select) and a
   "restricted parking / permit / paid bay" toggle. Keep it visually consistent
   with the existing stairs UI and keep the screen simple.
4. Surface the four new settings + the restricted fee in `settings.blade.php`
   (new "Parking & carry distance" section).
5. Include parking in the customer PDF **only** as a line in "Includes" when a
   carry/restriction applies (e.g. "Long carry at collection", "Permit parking at
   delivery") — never show the pound amount broken out, keep it in the total.
6. Add it to the "How is this price worked out?" info modal copy in plain English.
7. Add test cases to `PricingServiceTest.php`: light/standard/heavy at one end,
   different bands at each end summed, and the restricted add-on. Keep all tests green.
8. Update the migration/seed path is unnecessary (settings are seeded from DEFAULTS),
   but if a settings row is missing it should fall back to the DEFAULT — confirm
   `Setting::asArray()` already does this (it does).

Acceptance: I can pick a carry band + restricted toggle for each end, the price
updates live, the info modal explains it, the customer PDF mentions it without a
pound figure, Settings can change all the amounts, and `php artisan test` is green.

---

## 8. Likely follow-up tasks (context for planning, not to do yet)

- **Hide the Maps key fully**: move address autocomplete to a server-proxied
  Places endpoint so no key is exposed in the browser at all.
- **Booking status**: turn an accepted quote into a booking (status field, a
  calendar/day view, assign crew/van).
- **Multiple users & roles**: admin vs booker; audit who created/edited a quote.
- **Email the PDF** to the customer directly from the saved-quotes list.
- **Deposit / payment link** (the business already uses Stripe elsewhere).
- **Multi-tenant / white-label** (the owner intends to offer this to other removals
  firms later — keep tenant-boundary cleanliness in mind, but don't build it yet).

---

## 9. Environment reminders

- Local: XAMPP (Apache + MySQL). `php artisan serve` for the app.
- Before first run: `composer install`, `cp .env.example .env`,
  `php artisan key:generate`, create the `m4movers` MySQL database,
  `php artisan migrate --seed`.
- `bootstrap/cache/` must exist and be writable (it's in the repo with a
  `.gitignore` keeping the folder but ignoring compiled files).
- **Regenerate the Google Maps API key** before any public deployment and restrict
  it (referrer + only Maps JS / Places / Distance Matrix). Do not commit real keys.

---

## 10. How I'd like you (Cursor) to work

- Read `PricingService.php`, `Setting.php`, and `quotes/index.blade.php` before
  writing code.
- Make focused commits; keep the pricing test suite green.
- When adding a priceable factor, follow the full checklist in section 6.
- Ask before introducing a new dependency or changing the auth/model architecture.
- Keep the staff quote screen simple — that is the product's main virtue.

Start with the Parking feature in section 7.
