Skip to content

Unit reservation & its TTL

When a shopper (or a sales-man/broker via a shared link) starts to reserve a unit, the portal asks the CRM to hold the unit for a window of time. That hold — the reservation’s time-to-live — is created, counted, and expired entirely at the CRM. The portal forwards two duration values and otherwise does nothing with them: there is no local expiry job. When the hold lapses or is cancelled, the CRM pushes the change back through a webhook and the local unit_sale.status is reconciled. Verified against current backend code.

The portal forwards two separate hold windows to the CRM at two different steps:

Duration Sent when Forwarded by Config key
unit_block_duration Creating the sale (the very first block) CreateSaleCrmapp/Actions/Sale/CreateSaleCrm.php:35 config('variables.unit_block_duration') = env('UNIT_BLOCK_DURATION', 1)
update_info_duration Reserving (the window to submit buyer info) ReserveSaleCrmapp/Actions/Sale/ReserveSaleCrm.php:31 config('variables.update_info_duration') = env('UPDATE_INFO_DURATION', 1)

Both are read straight from env (default 1) in config/variables.php and both are flagged in the code as // get temporarily from env variable. The unit of the value (days vs hours) and the actual enforcement live in the CRM — the portal only passes the number along.

sequenceDiagram
participant SPA as Shopper SPA
participant API as Backend portal
participant CRM as CRM (via crms-middleware)
SPA->>API: POST units/{unit}/sale (create sale)
API->>CRM: create sale with unit_block_duration
CRM-->>API: 200 sale_source_id (unit now blocked)
API-->>SPA: local unit_sale status DRAFT
Note over API,CRM: the unit is HELD for the block window
SPA->>API: POST units/{unit}/{sale}/reserve (or card callback)
API->>CRM: reserve with update_info_duration
CRM-->>API: 200
API-->>SPA: local unit_sale status PENDING_INFO
alt Shopper finishes before the window elapses
  SPA->>API: submit info then form then finish-sale
  API-->>SPA: status DONE
else Hold window elapses at the CRM
  CRM->>API: PATCH sales/{saleSourceId} with crm_status
  Note over API: UpdateSale maps crm_status to<br/>DRAFT or CANCELED (hold released)
  API-->>SPA: unit is available again
end
Reserve, hold, then pay OR expire
  1. Create the sale — this places the hold. POST /api/shopper/units/{publishedAvailableUnitShopper}/sale (UnitSaleController@createUnitSaleapp/Http/Controllers/Api/Shopper/UnitSaleController.php:38). CreateSaleCrm posts to the CRM with unit_block_duration (app/Actions/Sale/CreateSaleCrm.php:35), which blocks the unit at the CRM. On a CRM 200, CreateSalePortal writes the local unit_sale row at status DRAFT.

  2. Reserve — extends into the info window. POST /api/shopper/units/{publishedUnitShopper}/{sale}/reserve (routes/api/shopper.php:103, UnitSaleController@reserveUnitSaleUnitSaleController.php:68). ReserveSaleCrm sends update_info_duration (app/Actions/Sale/ReserveSaleCrm.php:31). Only on a CRM 200 does the portal set status = PENDING_INFO and stamp payment_method (UnitSaleController.php:85-89). The card path reaches the same point from its gateway callback — see the buy-a-unit flow.

  3. A shared reservation link does NOT create a hold. A sales-man or broker can pre-fill a reservation via POST .../units/{publishedUnitSalesMan}/share-reservation-link (routes/api/sales-man.php:54, UnitController@shareReservationLinkapp/Http/Controllers/Api/SalesMan/UnitController.php:53). That only emails/SMSes the lead a deep link (ShareUnitReservationLinkAction). The hold is created when the lead actually creates the sale (step 1), not when the link is sent.

  4. The expiry mechanism is the CRM’s — surfaced by a webhook. There is no local scheduled job. The hold is released only when the CRM calls back: PATCH /api/crms-middleware/sales/{saleSourceId} (routes/api/crms-middleware.php:19, UnitSaleController@updateSaleapp/Http/Controllers/Api/CrmsMiddleware/UnitSaleController.php:12). The UpdateSale action (app/Actions/Sale/UpdateSale.php) maps the per-country crm_status onto a local UnitSaleStatus: a cancel code becomes CANCELED (Egypt UpdateSale.php:89-91, Oman 110-112, Montenegro 130-132), a bounced/draft code becomes DRAFT, and a reserved/contracted code becomes DONE. A released hold therefore shows up locally as CANCELED or back at DRAFT.

Two different enums describe “reserved”, for two different audiences.

UnitSaleStatus — the shopper’s own sale lifecycle

Section titled “UnitSaleStatus — the shopper’s own sale lifecycle”

Stored on unit_sale.status (app/Models/UnitSale.php:43, cast to the enum) — this is the shopper app’s step machine. Source: app/Enums/UnitSaleStatus.php.

Value Meaning for the hold
draft Sale created — the unit is now blocked at the CRM for the block window
pending_info Reserved (non-card or card callback) — inside the update_info_duration window to submit buyer info
download_form Buyer info submitted — hold still live
pending_finish Reservation form generated
done Sale finished — the hold has become a real reservation/deal
canceled CRM-owned only — pushed back via the webhook when the hold is cancelled/expired (a release)

ReservedSaleStatusEnums — the normalized pipeline status

Section titled “ReservedSaleStatusEnums — the normalized pipeline status”

The broker/sales-man sales lists show a normalized “reservation pipeline” status, derived live from the CRM sale status code (not from unit_sale.status). Each country enum maps its raw codes onto it via getReservedStatus() — e.g. app/Enums/EgyptCRMSaleStatusEnums.php:36-47 (also Oman and Montenegro). Source: app/Enums/ReservedSaleStatusEnums.php.

Value Maps from CRM codes such as
pending_odh_review draft / bounced-reservation / pending-on-sales-operation / pending-on-finance / pending-on-payment-collection / pending-on-head-of-sales
partially_reserved partially-reserved
reserved reserved
closed contracted / ready-for-handover / handover-in-progress / handover-on-hold / delivered
lost canceled (past deals)
  • No local expiry job — do not add one casually. Any “release stale holds” logic already exists inside the CRM. Adding a local timer would double-book against the CRM’s own countdown. The only local release path is the sales/{saleSourceId} webhook → UpdateSale.
  • The hold starts at DRAFT, not at reserve. Creating the sale blocks the unit. A shopper who created a sale but never reserved or paid is still holding the unit until the CRM’s block window lapses.
  • Two durations, two windows. unit_block_duration (create) and update_info_duration (reserve) are distinct. A unit can pass its info window while its underlying block is still what the CRM is counting — the exact interaction is CRM-defined, not visible here.
  • The duration values are env-driven and marked temporary. Both default to 1 and both carry a // get temporarily from env variable comment. The unit (days vs hours) is not asserted in this repo — the CRM decides. Check UNIT_BLOCK_DURATION / UPDATE_INFO_DURATION in the deployed env before quoting a number to anyone.
  • A shared reservation link holds nothing. share-reservation-link only notifies the lead. No hold exists until the lead creates the sale, so a link can be “shared” long before (or without ever) blocking the unit.
  • canceled is never set by the shopper app. It comes only from the CRM webhook. If a local row is canceled, the CRM released the hold — it is not a portal-side timeout.
  • If the webhook is missed, the local row lies. With no local timer, a lapsed hold that the CRM failed to push back will still read pending_info/draft locally. Reconcile against the CRM, do not trust the local status alone for stuck reservations.