# Rental Estimate (public estimate → verified lead)

**Last updated:** 2026-09-10
**Portals:** Main (public wizard) + Manage (admin queue and Lead tab) ·
**Namespace:** `Src\RentalEstimate` ·
**Routes:** `public.rental-estimate*`, `manage.rental-estimates.*`

**Nav:** Sales → **Rental**, which carries the shared Pipeline → Sales → Referral & Repeat stage rail
(GUIDELINES §15). This queue **is** that line's ① Pipeline stage; Tenancies (②) and Landlords (③) are
`soon`. No sidebar entry of its own. The index header carries a **Landing Page** button opening the
public wizard (`/my/rental-estimate`) in a new tab — a plain `<a>`, and a **relative** href so it
opens the landing page on whatever host the admin is already on rather than jumping to production.

A bilingual, country-aware lead funnel that matches a visitor's unit to a
[Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md) floor plan, freezes an
honest furnishing-adjusted monthly-rent estimate, verifies both contact keys, and then reveals the
result. Staff follow the capture through `/manage/rental-estimates` or the linked Lead's
**Rental Estimate** tab.

## What it does

- Runs a seven-step wizard:
  `project → bedrooms → furnishing → contact/confirmation → verification → result → CTA`.
  The project must come from the active country's Catalogue; typing an arbitrary property
  name is not a valid live submission. Step 1 shows the active country's default map immediately;
  selecting a project recentres it and adds a draggable pin. Fine-tuning the pin changes the
  submitted coordinates but never changes the selected Catalogue project.
- Captures the request and computes the estimate **before** OTP verification, so an abandoned
  verification still leaves an actionable unverified row. A browser session owns one unverified
  capture: correcting contact or unit details updates that row in place. Verification freezes the
  full capture and estimate snapshot.
- Keeps the estimate private until **both** email and phone codes succeed. Pre-verification search
  returns project identity/coordinates, preview-match returns only plan name/area/layout, and submit
  returns only the session-owned submission UUID. The stored rent band leaves the server only in
  the successful verify response.
- Reuses the same
  [`ContactVerification.vue`](/resources/js/Components/Auth/ContactVerification.vue) and
  [`PasswordlessAuth`](/app/Services/Auth/PasswordlessAuth.php) dual-key identity matrix as
  `/register` and [Property Match](/docs/modules_handbook/main/property-match/readMe.md), but does
  **not** sign the visitor in. Verification is committed before CRM linking; a CRM or owner-mirror
  failure cannot roll back verification or withhold the reveal. Verified rows that miss the CRM link
  are retried idempotently by scheduled maintenance.
- Stores public inputs, matched-plan facts and the displayed estimate as a reproducible snapshot.
  The Manage detail deliberately shows this snapshot separately from the current Catalogue facts.
- Tracks verified-only WhatsApp consultation clicks without delaying the external `wa.me` CTA.
  Malaysia receives renovation-oriented CTA copy; other markets receive general consultation copy.

## How it works

### Estimate and Catalogue matching

[`FloorPlanMatcher`](/src/RentalEstimate/Support/FloorPlanMatcher.php) reads only
`catalog_projects` and their `catalog_floor_plans`. Studio requests match studio-key plans; other
requests match the requested bedroom count. When sqft is supplied, nearest size wins. The
deterministic tie-breaks then prefer a stronger estimate source, fresher facts and the lowest row id.
Without sqft, source strength and unit count lead the ordering.

[`EstimateCalculator`](/src/RentalEstimate/Support/EstimateCalculator.php) uses this whole-unit rent
precedence:

1. `rent_median`; a low/high range is included only when **both** `rent_low` and `rent_high` are
   present and satisfy `rent_low <= rent_median <= rent_high`. An incomplete or invalid range
   collapses to the median point rather than exposing a contradictory band.
2. `rental_price` as a point estimate.
3. No usable whole-unit fact → no estimate is invented; the row is labelled for manual estimation.

`CatalogFloorPlanAnalytics.total_room_rental` is deliberately excluded: it is the sum of room rents,
not the rent of the whole unit. The separately-labelled room-rental-strategy estimate remains
deferred.

The base values are multiplied by furnishing level and rounded to the nearest 50:

| Stored id | Furnishing | Factor |
| ---: | --- | ---: |
| `1` | Bare | `0.85` |
| `2` | Partial | `1.00` |
| `3` | Fully furnished | `1.15` |

Only the stable integer id is stored; localized labels are resolved at the presentation layer.

`matched_plan`, low/mid/high, currency, source, facts timestamp and missing-data reason are written
onto [`rental_estimate_submissions`](/database/migrations/2026_07_24_100001_create_rental_estimate_submissions_table.php).
The visitor may re-capture the row while it is unverified; `markVerified()` is the boundary after
which the snapshot is immutable.

### Public request and privacy boundary

The country-less `/rental-estimate` route uses `public.site`'s cookie/default market and redirects to
`/{country}/rental-estimate`. The country-prefixed routes all inherit `public.site`:

| Route | Purpose |
| --- | --- |
| `GET /{country}/rental-estimate` | Render the wizard. |
| `GET /{country}/rental-estimate/search` | Search projects in the active country. |
| `GET /{country}/rental-estimate/preview-match` | Return matched plan identity only — never rent values. |
| `POST /{country}/rental-estimate/submit` | Store/update the unverified capture and private estimate. |
| `POST /{country}/rental-estimate/start` | Send both verification codes for that capture's contacts. |
| `POST /{country}/rental-estimate/verify` | Prove both keys, freeze the row, attempt CRM link, reveal snapshot. |
| `POST /{country}/rental-estimate/wa-click` | Count a verified, session-owned consultation click. |

**The published-only guard is temporarily disabled.** `search`, `previewMatch` and `submit` each
carried a `->published()` scope, so only Catalogue records with a `published_at` were quotable.
Nothing in the imported Catalogue has been published yet, which made every search return nothing, so
the three calls are commented out (not deleted) with a pointer back to the note in `search()`.
Uncomment all three to restore the guard once records are published from
**Manage → Property → Catalog**; `test_preview_and_submit_accept_unpublished_projects_while_the_guard_is_off`
in the flow test then flips back to its reject shape.

Submit/start/verify are throttled and session-blocked. Start, verify and WhatsApp tracking require
the UUID to equal the browser session's `re_submission`; the OTP challenge identities must also
match the capture's current email and phone. Wrong sibling codes do not consume the correct code.

After both codes pass,
[`RentalEstimateRepository::markVerified`](/src/RentalEstimate/Repositories/RentalEstimateRepository.php)
runs first. CRM linking then uses the shared identity foundation and attaches the resolved lead
separately; only a genuinely new lead receives Rental Estimate first-touch attribution. See
[Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) and
[Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md) for the identity rules.

`rental-estimate:maintain` retries verified rows whose `lead_id` is still null every ten minutes.
Optional hard-retention cleanup is registered daily but ships disabled. After the approved privacy
policy is confirmed, set `RENTAL_ESTIMATE_RETENTION_ENABLED=true` and configure the unverified and
verified-unlinked day limits. Legacy imports and lead-linked rows are excluded; expired public rows
and their private remarks are permanently deleted together.

### Assignment: mirror-only v1

[`RentalEstimateAssigner`](/app/Services/RentalEstimate/RentalEstimateAssigner.php) does **not**
push a lead through the distribution engine. For a verified, linked, unassigned submission, it only
copies the lead's existing owner when the lead is already `DIST_ASSIGNED`. It never writes
`leads`, `lead_assignments`, a distribution reason, or quota usage. Otherwise the submission remains
visible in the unassigned queue for manual handling.

A manual assignment is sticky: automatic retries cannot replace it, and an already assigned row is
not overwritten. Staff may retry the mirror after the linked lead later receives an owner.

True push assignment is deferred until the mentor defines the project/group/person rule, quota
semantics, assignment reason, and behaviour for exhausted quota or a lead outside `DIST_WAITING`.

### Manage queue and Lead history

`/manage/rental-estimates` requires `view-rental-estimate`; all mutations require
`manage-rental-estimate`. It provides search and country/status/verification/assignee/date/estimate/
WhatsApp filters, headline counts, sortable paginated rows, frozen-versus-current detail, inline
assignment, tags, and append-only remarks. The index returns only each row's remark count; the full
history is fetched newest-first in cursor-paginated batches when staff expand it, keeping both large
queues and long-lived browser sessions bounded.

The enforced pipeline is:

- **The board is a DIRECT status update (2026-09-10):** `Enquiry → Key Collected → Closed`, plus
  `Lost` — every status may move to every other, never to itself (so a row a colleague already
  moved is refused rather than silently re-saved). No review or approval step sits between them;
  that is the Renovation line's shape, not this one's. Verification is the `is_verified` FLAG beside
  the status, not a status: `markVerified()` no longer moves it. The old six-step ladder
  (New / Verified / Contacted / Quoted / Won / Lost) was remapped by `2026_09_10_000002` — the first
  four fold into Enquiry, Won → Closed; `STATUS_NEW` survives as a deprecated alias of Enquiry because
  the create migration reads it for its column default (GUIDELINES §7).
- **CLOSED is not a status anybody sets (2026-09-20).** An enquiry closes **into a TENANCY** —
  founder: *"if the status is closed, means move to sales tenancies stage and user need to key in
  tenant name, starting and ending tenancy period and how much commission confirmed"*. Picking
  Closed on the board opens the tenancy form; `POST /manage/rental-estimates/{id}/tenancy`
  ([`TenanciesController@store`](/app/Http/Controllers/Manage/RentalEstimate/TenanciesController.php))
  writes the tenancy **and** moves the status in one transaction
  ([`RentalTenancyRepository::close()`](/src/RentalEstimate/Repositories/RentalTenancyRepository.php)),
  so a Closed enquiry with no tenant, no period and no commission cannot exist. The status endpoint
  refuses Closed while no tenancy exists — in `UpdateStatusRequest` (a validation error the board
  shows) and again in `changeStatus()` (for any other caller). Re-closing one that still has its
  tenancy is allowed, and rows closed before this shipped keep their status untouched.
- **Tenancies are their own table, one enquiry to many** (`rental_tenancies`,
  [`RentalTenancy`](/src/RentalEstimate/RentalTenancy.php)): a unit is let more than once — this
  year's tenant, next year's renewal — and each is a row with its own period and its own **confirmed
  commission**. That confirmed figure is deliberately NOT the submission's `commission_amount`,
  which stays what it always was (rent × months, a projection): the form prefills from it, a person
  types what was actually agreed, and the Tenancies list shows the difference where there is one.
  `months` counts the end date as a day OF the tenancy (1 Oct 2026 → 30 Sep 2028 is 24 months, not
  23). Removing an enquiry's last tenancy walks it back to Key Collected rather than leaving a
  Closed row with nothing behind it. Pinned by `tests/Feature/RentalEstimate/RentalTenancyTest.php`.
- ⚠️ **Both writers LOCK THE SUBMISSION FIRST, and the order is load-bearing** (fixed 2026-09-21).
  MySQL runs at `REPEATABLE READ`, where a plain `count()` answers from the snapshot the
  transaction opened at its **first consistent read** — so `RentalTenancyRepository::delete()`
  counting the remaining tenancies *before* taking the parent's `lockForUpdate()` let two admins
  deleting the last **two** tenancies of one enquiry each still see the other's row: both computed
  `remaining = 1`, neither walked the enquiry back, and it stayed **Closed with nothing behind it** —
  precisely the state this module exists to prevent. Reproduced on this database with two
  connections before the fix, and gone after it. `close()` had always locked first; `delete()` had
  the two statements the other way round. If you add a third writer here, take the lock before you
  read anything you intend to branch on.
- **The Sales stage is real now** — `/manage/rental-tenancies`
  ([`Tenancies.vue`](/resources/js/Pages/Manage/RentalEstimate/Tenancies.vue)), the Rental line's
  middle chevron in `SalesTabs` (it was a "Soon" placeholder). Ordered by the date each tenancy
  ENDS, soonest first, which is why `ends_on` is indexed: the next question this table answers is
  whose tenancy expires next.
- **Staff can key an enquiry in** — **New enquiry** on the board (`POST /manage/rental-estimates`,
  `manage-rental-estimate`), for a landlord who phoned or walked in. Same capture as the wizard;
  the unit is a catalogue project (searched per market via `GET /manage/rental-estimates/projects`)
  OR a typed property name when the catalogue does not have it (kept in `legacy_property_name`, the
  column the board already prints for a unit without a project; the row is flagged for a manual
  estimate). The estimate is frozen by the SAME builder the wizard uses —
  [`EstimateSnapshot`](/src/RentalEstimate/Support/EstimateSnapshot.php) — so the number is the
  same whichever door it came through. `RentalEstimateRepository::createManual()` assigns the row to
  whoever keyed it in (they took the enquiry; the mirror-only auto-assigner has no lead to mirror)
  and stamps `created_by`, which is how the board tells a **Manual** row (violet chip, with the
  author's name) from a public one (`created_by` null) or a petaV2 import. Never verified — nobody
  proved the contact keys — and not linked to a lead.
- **The deal (2026-09-10).** The estimate is a projection; once the status is **Key Collected** or
  **Closed** (`DEAL_STATUSES` — `changeStatus()` stamps `key_collected_at` / `closed_at` once each)
  the row's expand panel opens a **Deal** card: the **final monthly rent**, the **commission in
  months of rent** (default 1; `commission_amount = final_rent × months`, stored for reporting) and
  **who earns it** — `rental_estimate_commissions`, one row per staff member with a `share` % of the
  whole. A blank share takes an equal slice of what the explicit ones leave over, the booking
  pipeline's rule, so an untouched split is even and the rows always total 100;
  `RentalEstimateSubmission::commissionBreakdown()` is the single source of that arithmetic and
  `DealPanel.vue` mirrors it for the live preview. `RentalEstimateRepository::setDeal()` writes the
  rent, the derived figure and the child set in one transaction (a saved rent with a stale split
  would pay somebody the wrong number) and refuses any other status. People are resolved through
  the assignee picker's pool (`assignableManagerQuery`), so a uuid outside it is refused. The
  Estimate column prints the final rent in place of the projection once it exists.

The Lead Show page's **Rental Estimate** tab lists every linked submission with its submitted unit,
verification state, frozen estimate and CTA activity. It inherits the Lead page's existing
visibility rules; the standalone queue uses the Rental Estimate permission pair above.

`rental_estimate_submissions.lead_id` participates in the identity child map: account merges repoint
it to the survivor and privacy purge deletes the lead-owned submission plus its append-only remarks.
Staff assignment columns are not identity-owned.

### Legacy petaV2 import

The import is separate from deployment and is safe to preview:

```bash
php artisan rental-estimate:import-legacy /path/to/rental-estimates.csv --dry-run
php artisan rental-estimate:import-legacy /path/to/rental-estimates.csv
```

The CSV header must exactly match the command's documented petaV2 export contract. Contact keys are
**canonicalised before anything else uses them**: the email is trimmed and lowercased, the phone is
reduced to digits via `PhoneNumber::digits()` (exactly like the live funnel), and those canonical
values are what get persisted AND what feed the source-payload hash (`legacy_meta.payload_sha256`,
a sha256 of the parsed row minus derived flags). Each row receives `legacy_source_id = petav2:{id}`.
Re-running a row whose canonical payload is identical — including rows that differ only in email
case or phone formatting — skips it; reusing the id with genuinely changed source data reports a
conflict and leaves the stored row untouched. Dry-run performs no writes. This normalisation must be
deployed before the first production import; rows imported without it would need a manual review
before any backfill.

Legacy rows are MY/MYR, keep the historical estimate and timestamps, and attempt only an exact
case-insensitive MY Catalogue project-name match. An unmatched name remains in
`legacy_property_name`. They are deliberately imported as `is_verified=false`, `lead_id=null`,
`status=New`: historical free-text contacts never claim a current CRM identity. Import validation
requires `est_found=false` rows to carry no estimate values; `est_found=true` requires a midpoint and
allows either a point estimate or a complete ordered `low <= mid <= high` range.

### Operations and testing

- Run the module tests with
  `php artisan test tests/Feature/RentalEstimate tests/Unit/RentalEstimate`.
- Run the wizard unit test with
  `npm run test -- resources/js/Pages/Main/RentalEstimate/wizard.test.js`, then `npm run build`.
- CRM repair runs automatically. Before enabling destructive retention, approve the policy and set:
  `RENTAL_ESTIMATE_RETENTION_ENABLED=true`,
  `RENTAL_ESTIMATE_UNVERIFIED_RETENTION_DAYS` (default `30`), and
  `RENTAL_ESTIMATE_VERIFIED_UNLINKED_RETENTION_DAYS` (default `90`).
- Exercise both an estimate range and point result, the manual-estimate path, MY and non-MY CTA
  copy, Chinese copy, contact correction, dual-code failure/success, owner retry, manual assignment,
  tags/remarks, and the Lead tab. Confirm that no network response contains rent values before
  successful verification.
- The map currently loads tiles directly in the visitor's browser from
  `tile.openstreetmap.org`. This exposes the visitor's IP address and the requested map tiles
  (therefore an approximate viewed location) to that third party. Before production, explicitly
  accept and disclose that processing or move to an approved proxied/self-hosted/contracted tile
  service. The module does not use Nominatim or send contact details to the tile URL.

## Units PropertyLab sold — opened automatically at VP (2026-09-23)

**Letting is the DEFAULT service, so it is not a question** (owner): a buyer who renovates the place
himself still needs us to rent it out, and if the team cannot get a duplicate key that is our failure
to fix, not a reason for the unit to be missing from the board. So a project's units arrive here on
their own.

**The trigger is the VP date.** A colleague keying it in on the sales project IS the statement that
vacant possession happened, so `SalesProjectsController@update` runs
`App\Actions\PostVp\OpenRentalEnquiry` over every live booking of that project. It fires only when
the date goes from ABSENT to set — correcting a date does not re-open anything, and the action is
idempotent per booking anyway.

Two columns made this possible, both added 2026-09-23:

- **`booking_id`** — the unit this enquiry is about, and the join the post-VP tracker's 招租 stage
  needs to read a real status.
- **`unit_number`** — which this table simply did not have. Live rows were typing "23-13" into
  `location_label`, a field meant for an address, because there was nowhere else to put it.

**What an opened enquiry states.** The project, the layout, its size and bedrooms, the unit number,
the buyer's own contact — and **the unit's own forecast rent**, source `unit_forecast`: the same
figure its project page, the colleague's leads table and the buyer's dashboard quote, a colleague's
rent-basis ruling included ({@see `Src\PostVp\Support\UnitEconomics`}). Landing these rows in the
board's *manual estimate needed* pile would be asking the team to work out a number the platform has
already worked out.

The band around it is the layout's own recorded `rent_low` / `rent_high` when it has them; with none,
the row carries the point figure alone rather than an invented range. `furnishing` is `bare` because
nobody has asked the buyer yet, and a unit with no layout named carries no estimate at all — there is
nothing to read one from.

⚠️ `post-vp:open-rental-enquiries` also fills the forecast onto rows opened BEFORE it did so, but
**only where the estimate is blank**: a figure the team worked out by hand, or one a member's
verified wizard run produced, outranks ours and is never overwritten by a re-run.

The table marks these **"Our buyer's unit"**.

**Two doors, because one is not enough.** `SalesProjectsController@update` opens them on ANY save of
a project that has a VP date — not only the save that set it, which would have missed every project
already past VP, i.e. all of them. And
`php artisan post-vp:open-rental-enquiries [--project=uuid] [--apply]` runs the same action over
everything, so a unit does not wait for somebody to re-save a project. It is a dry run without
`--apply`, and idempotent, so it is safe nightly.

Maxim Risen's 21 units were opened this way on 2026-09-23.

## Related files (tenancies)

- [database/migrations/2026_09_20_100000_create_rental_tenancies_table.php](/database/migrations/2026_09_20_100000_create_rental_tenancies_table.php) — `rental_tenancies`: uuid + `submission_id` / `lead_id` (indexed, no FK), `tenant_name`, `starts_on`, `ends_on` (indexed — "whose tenancy ends next"), `commission_amount`, `currency`, `notes`, blame + soft deletes.
- [src/RentalEstimate/RentalTenancy.php](/src/RentalEstimate/RentalTenancy.php) · [Repositories/RentalTenancyRepository.php](/src/RentalEstimate/Repositories/RentalTenancyRepository.php) — `close()` (tenancy + status, one transaction), `update()`, `delete()` (walks the enquiry back when it was the last one).
- [app/Http/Controllers/Manage/RentalEstimate/TenanciesController.php](/app/Http/Controllers/Manage/RentalEstimate/TenanciesController.php) · [app/Http/Requests/Manage/RentalEstimate/TenancyRequest.php](/app/Http/Requests/Manage/RentalEstimate/TenancyRequest.php) (`ends_on` must be after `starts_on`).
- [resources/js/Pages/Manage/RentalEstimate/Tenancies.vue](/resources/js/Pages/Manage/RentalEstimate/Tenancies.vue) · [Partials/TenancyFormModal.vue](/resources/js/Pages/Manage/RentalEstimate/Partials/TenancyFormModal.vue) — ONE modal for both doors: closing from the board, and editing on the stage.
- Routes: `POST /manage/rental-estimates/{id}/tenancy`, `GET|PUT|DELETE /manage/rental-tenancies[/{id}]` (`view-rental-estimate` to read, `manage-rental-estimate` to write — no new permission, so no seeder run is needed on production).

## Related files

- **Backend:** [RentalEstimateSubmission](/src/RentalEstimate/RentalEstimateSubmission.php) ·
  [RentalEstimateRemark](/src/RentalEstimate/RentalEstimateRemark.php) ·
  [RentalEstimateRepository](/src/RentalEstimate/Repositories/RentalEstimateRepository.php) ·
  [EstimateCalculator](/src/RentalEstimate/Support/EstimateCalculator.php) ·
  [FloorPlanMatcher](/src/RentalEstimate/Support/FloorPlanMatcher.php) ·
  [RentalEstimateAssigner](/app/Services/RentalEstimate/RentalEstimateAssigner.php) ·
  [RentalEstimateLeadLinker](/app/Services/RentalEstimate/RentalEstimateLeadLinker.php) — ⚠️ it runs
  [`PasswordlessAuth::completeRegistration`](/app/Services/Auth/PasswordlessAuth.php), the SAME identity
  matrix as `/register`, so a verified submission can **create, supersede or silently AUTO-MERGE**
  accounts (minus the sign-in). Third consumer of that matrix alongside `/register` and Property Match —
  see [Users · Leads · Admins · Login · Register · Merge](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) §9.
- **Public:** [RentalEstimateController](/app/Http/Controllers/Main/RentalEstimate/RentalEstimateController.php) ·
  [public Form Requests](/app/Http/Requests/Main/RentalEstimate) ·
  [Index.vue](/resources/js/Pages/Main/RentalEstimate/Index.vue) ·
  [content.js](/resources/js/Pages/Main/RentalEstimate/content.js) ·
  [wizard.js](/resources/js/Pages/Main/RentalEstimate/wizard.js) ·
  [ContactVerification.vue](/resources/js/Components/Auth/ContactVerification.vue) ·
  [HandleInertiaRequests](/app/Http/Middleware/HandleInertiaRequests.php)
- **Manage:** [SubmissionsController](/app/Http/Controllers/Manage/RentalEstimate/SubmissionsController.php) ·
  [manage Form Requests](/app/Http/Requests/Manage/RentalEstimate) ·
  [Manage/RentalEstimate/Index.vue](/resources/js/Pages/Manage/RentalEstimate/Index.vue) ·
  [RentalEstimateTab.vue](/resources/js/Pages/Manage/Leads/Partials/Tabs/RentalEstimateTab.vue) ·
  [LeadsController](/app/Http/Controllers/Manage/Leads/LeadsController.php) ·
  [Leads/Show.vue](/resources/js/Pages/Manage/Leads/Show.vue) ·
  [ManageLayout.vue](/resources/js/Layouts/ManageLayout.vue) ·
  [useOperationsLanding.js](/resources/js/composables/useOperationsLanding.js)
- **Commands:** [RentalEstimateImportLegacy](/app/Console/Commands/RentalEstimateImportLegacy.php) ·
  [RentalEstimateMaintain](/app/Console/Commands/RentalEstimateMaintain.php) ·
  [retention config](/config/rental_estimate.php)
- **Migrations:** [rental_estimate_submissions](/database/migrations/2026_07_24_100001_create_rental_estimate_submissions_table.php) ·
  [rental_estimate_remarks](/database/migrations/2026_07_24_100002_create_rental_estimate_remarks_table.php)
- **Routes and permissions:** [routes/main.php](/routes/main.php) · [routes/web.php](/routes/web.php) ·
  [Permission](/src/Auth/Permission.php) · [RolesSeeder](/database/seeds/RolesSeeder.php) ·
  [IdentityChildMap](/src/Lead/Support/IdentityChildMap.php)
- **Tests:** [Rental Estimate feature tests](/tests/Feature/RentalEstimate) ·
  [EstimateCalculatorTest](/tests/Unit/RentalEstimate/EstimateCalculatorTest.php) ·
  [wizard.test.js](/resources/js/Pages/Main/RentalEstimate/wizard.test.js)

**Added 2026-09-10 (manual entry + direct status board)**
- `src/RentalEstimate/Support/EstimateSnapshot.php` — the one estimate builder for both doors.
- `app/Http/Requests/Manage/RentalEstimate/StoreRequest.php`
- `resources/js/Pages/Manage/RentalEstimate/Partials/EnquiryFormModal.vue`
- `resources/js/Pages/Manage/RentalEstimate/Partials/DealPanel.vue` — final rent, months, the split.
- `src/RentalEstimate/RentalEstimateCommission.php` — one person's share (child, no uuid).
- `app/Http/Requests/Manage/RentalEstimate/DealRequest.php`
- `database/migrations/2026_09_10_000002_simplify_rental_estimate_statuses.php`
- `database/migrations/2026_09_10_000003_add_deal_to_rental_estimate_submissions.php`

## Related modules

- [Property Match](/docs/modules_handbook/main/property-match/readMe.md) — sibling verified public
  funnel and the shared verification integration pattern.
- [Users · Leads · Admins · Login](/docs/modules_handbook/shared/user-lead-admin-login-register-merge/readMe.md) —
  dual-key verification, identity merge/review rules and the lead/user relationship.
- [Lead Linking](/docs/modules_handbook/shared/lead-linking/readMe.md) — common CRM identity and
  attribution concepts used after contact proof.
- [Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md) — the only live
  project and floor-plan source used for matching and estimate facts.
