# 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).
- **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.

## 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.
