<!--
	covers: captcha controllers/ContactController.php controllers/ComparisonController.php controllers/ProposalRequestController.php models/CallbackRequest.php models/Contact.php models/ProposalRequest.php models/ProposalRequestItem.php models/ProposalRequestService.php models/ProposalRequestStatusChange.php models/forms services/SessionListService.php widgets/ContactSidebarWidget.php
	verified: 54fd0a6
-->
# Proposals and leads

Everything between "a visitor is interested" and "there is a contract".

## `ProposalRequest` — the quote request

[`models/ProposalRequest.php`](../../models/ProposalRequest.php). The main funnel entry: a visitor collects
storage units, adds their details, and submits.

| Column group | Columns |
|--------------|---------|
| Link | `userId` (0 when the requester has no account), `fileId` (the generated offer document) |
| State | `status`, `version` |
| Person | `lastName`, `firstName`, `email`, `phone` |
| Company | `isCompany`, `companyName`, `taxNumber` |
| Text | `comment` (from the visitor), `documentComment` (for the offer document) |

### Statuses are sortable strings

```php
STATUS_NEW         = '10_New'
STATUS_IN_PROGRESS = '20_InProgress'
STATUS_SENT        = '30_Sent'
STATUS_APPROVED    = '40_Approved'
STATUS_DECLINED    = '50_Declined'
```

The numeric prefix is deliberate: the column sorts into workflow order without a lookup table. Unlike
`RentStatus` / `PaymentStatus` there is **no `next()` transition machine** here — `getStatuses()` returns all
of them and any transition is allowed.

`ProposalRequestStatusChange` records the history, same pattern as rentals.

### Two behaviours worth knowing

**Account linking happens in `beforeSave()`:**

```php
$this->userId = $this->userId ?: User::findOne( [ 'email' => $this->email ] )?->id ?? 0;
```

A request submitted with an e-mail that matches an existing account is silently attached to it — which is how
it appears under "my quote requests" without the visitor logging in. It resolves **on every save**, so an
account registered later does not retroactively pick up old requests unless the record is saved again.

**`fileId` becomes required at status `Sent` / `Approved` / `Declined`**, via a conditional rule. Moving a
request forward without generating the offer document fails validation.

`computedName` is added to `attributes()` without being a column — a virtual attribute so the admin grid can
sort and filter on the requester's full name.

## `ProposalRequestItem` — one quoted unit

| Column group | Columns |
|--------------|---------|
| Link | `proposalRequestId`, `storageId`, `companyId` |
| Pricing, net | `firstMonthPriceNet`, `monthlyPriceNet`, `lastMonthPriceNet`, `totalPriceNet`, `depositPriceNet` |
| Discount | `discountPercentage` |
| Term | `dateFrom`, `dateTo`, `isPrepaid` |
| Text | `documentComment` |

The pricing columns mirror `Rent`'s, plus `totalPriceNet` and `discountPercentage`. **This is the record a
`Rent` is created from** — `Rent.proposalRequestItemId` points back at it, and
`modules/admin/action/CreateRent.php` performs the conversion.

`ProposalRequestService` (the **model** in `models/`, not the service in `services/`) joins add-on `Service`
records to a request.

`ProposalRequestItemCalculator` in `modules/admin/models/` does the price arithmetic — for the admin form, for
`Rent`, for the generated offer document and for `PaymentHelper`. The rules, all covered by
[`tests/unit/modules/admin/models/ProposalRequestItemCalculatorTest.php`](../../tests/unit/modules/admin/models/ProposalRequestItemCalculatorTest.php):

- A **full month** is a calendar month lying entirely inside the interval. `2026-03-10 … 2026-06-20` has two
  (April, May); the partial ends are billed by the day instead.
- `firstMonthDays` / `lastMonthDays` are **inclusive calendar day counts**, computed on dates rather than
  timestamps so a daylight saving switch cannot eat a day.
- The **price tier** is chosen from the full month count: 12+ → `price12M`, 6+ → `price6M`, 3+ → `price3M`,
  otherwise `price1M`. Getting the month count wrong therefore gets the *unit price* wrong, not just the total.
- The daily price is `round( monthlyPrice / 30 )` — a flat thirtieth, regardless of how long the month is.
- The discount is applied to each price separately and rounded, so the discounted total is not necessarily the
  sum of the discounted parts.

### Occupancy effect

An item with no rent yet, on a non-declined request, counts as `pending` occupancy in
`StorageService::usage()`. So a quote holds the unit visually on the calendar without reserving it
contractually. See [STORAGE-AND-LOCATIONS.md](STORAGE-AND-LOCATIONS.md).

## The contact sidebar — where a quote request starts

[`widgets/ContactSidebarWidget.php`](../../widgets/ContactSidebarWidget.php) renders a floating button in
every page's corner (`views/layouts/main.php`). The panel itself is fetched from `contact/sidebar` on the
first click and cached in the DOM, so its cost is not paid on pages nobody opens it on.

| Section | Form | Endpoint |
|---------|------|----------|
| Contact details | — | — |
| Quote request | `models/forms/Offer.php` | `contact/send-offer` |
| Callback | `models/forms/CallbackRequest.php` | `contact/send-callback` |
| Message | `models/forms/Contact.php` | `contact/send-contact` |

All three post over AJAX and answer with the JSON an `AlertDialog` shows. Routes are localized
(`{contact}/sidebar`, `{contact}/send-*`), so the panel arrives in the language of the page it opened on.

**Anything with a `.-sidebar-<section>` class opens it** at that section — the header and footer menu items,
and the quote-request button on every storage card
([`views/parts/storage-offer.php`](../../views/parts/storage-offer.php)). A `data-url` on the trigger
overrides which storage the offer form starts with; without one the widget's own URL is used, which already
carries the storage of the page being viewed.

Things worth knowing about the panel:

- **The captcha is a plain `<img>`**, not `yii\captcha\Captcha`. The panel arrives through `$.get`, so no
  asset the widget registers would ever run. `ContactSidebarWidget.js` refreshes it through
  `site/captcha?refresh=1` after every submit.
- **`models\CallbackRequest` requires a `subject`** and the sidebar asks for none, so the form carries it in
  a hidden input: the storage's display name, or `t( 'callbackTitle' )`.
- **`forms\CallbackRequest::getCodeName()` returns `'callback'`**, not the class name — its labels and
  messages live under `callback*` keys.
- The offer form's "from when" is preseeded from `ProposalRequestService::defaultFrom()`, which is what the
  storage search remembered.

## `Offer` builds the request

[`models/forms/Offer.php`](../../models/forms/Offer.php) carries `storageId`, `from` and `duration`, and
`items()` turns them into the single `storageId => "from|duration"` pair `run()` creates the item from.
Without a `storageId` it falls back to the session list, which is what the parked cart page used.

`storageOptions()` lists every published storage for the sidebar's searchable picker
(`resources/js/components/StoragePicker.js`), used when no storage is in context — from the footer, say.

## The session lists — comparison, and the parked offer list

[`services/SessionListService.php`](../../services/SessionListService.php) backs two lists:

| List | Service | Routes | State |
|------|---------|--------|-------|
| Comparison | `ComparisonService` | `{comparisonAdd}/<id>`, `{comparisonRemove}/<id>`, `{comparison}` | live |
| Offer / quote | `ProposalRequestService` | — | **parked** |

**The comparison list lives in the session, not the database.** It does not survive a session expiry, it is
not visible to the back office, and it cannot be recovered.

**The offer list is unreachable.** Its URL rules are gone and
[`ProposalRequestController::beforeAction()`](../../controllers/ProposalRequestController.php) throws a 404 —
removing the rules alone was not enough, because the URL manager falls back to plain route parsing and
`/proposal-request/add` would have kept writing the session. The controller, the service, the view and the
`proposalRequestIndex` entry are all still in the repository; the flow may come back. See
[ADR 0024](../adr/0024-quote-request-is-a-sidebar-not-a-cart.md).

`ProposalRequestService` is still used for what the sidebar needs: `FROMS`, `DURATIONS`, `fromOptions()`,
`durationOptions()`, `dates()` and the remembered `defaultFrom()`.

## `CallbackRequest` and `Contact`

Both are simple lead records with their own `STATUS_*` constants, a `getStatuses()` list, a
`renderStatus()` helper and `HasNotifications` for the dashboard counter.

- **`Contact`** — the contact form. `Contact::initData()` seeds reference data.
- **`CallbackRequest`** — "call me back", merged into the offer tabs on the frontend.

Both send mail through [`Mailer::autoConfigure()`](../../components/Mailer.php) with the views
`mail/contact.php` and `mail/callback-request.php`. The public forms are protected by
[`captcha/CaptchaAction`](../../captcha).

Form models live in [`models/forms/`](../../models/forms): `Contact`, `CallbackRequest`, `Offer`,
`StorageSearch` — these extend `app\models\forms\Form`, **not** `ActiveRecord`.

> **Another name collision**: `models/Contact.php` (the record) and `models/forms/Contact.php` (the form).
> Same for `CallbackRequest`. Check the `use` statement.

## Frontend flow

```
storages/index  ─ filter ─►  storages/display
       │                            │
       └──────────┬─────────────────┘
                  │  .-sidebar-offer          comparison list (session)
                  ▼
          contact/sidebar  ─ submit ─►  contact/send-offer
                                          │
                                          ▼
                                   ProposalRequest + 1 Item   (database)
                                          │  admin: GenerateOffer → fileId
                                          │  admin: CreateRent
                                          ▼
                                        Rent
```

See [RENTAL-AND-BILLING.md](RENTAL-AND-BILLING.md) for what happens after.

## Traps

1. **`userId` is resolved by e-mail on every save.** Changing the e-mail on an existing request can re-point
   it at a different account.
2. **The status prefixes are part of the stored value.** `'10_New'`, not `'New'` — never compare against the
   bare word.
3. **No transition validation on proposal statuses**, unlike rentals and payments.
4. **The comparison list is unrecoverable.** A visitor who loses their session loses the whole selection,
   and there is nothing to look up.
5. **`ProposalRequestService` exists twice**, as a model and as a service.
6. **`fileId` gates the status transition**, so a failed document generation blocks the workflow with a
   validation error that names the file field, not the generator.
7. **A quote request now always has exactly one item.** Everything downstream — the offer document, the
   rental conversion — was written for a list and still works, but nothing produces multi-item requests.
8. **`ProposalRequestController` 404s on every action.** Reading its code and concluding the cart still works
   is the mistake to avoid; check `beforeAction()`.
