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 two durations
Section titled “The two durations”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) | CreateSaleCrm — app/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) | ReserveSaleCrm — app/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.
The hold lifecycle
Section titled “The hold lifecycle”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
endStep by step
Section titled “Step by step”-
Create the sale — this places the hold.
POST /api/shopper/units/{publishedAvailableUnitShopper}/sale(UnitSaleController@createUnitSale—app/Http/Controllers/Api/Shopper/UnitSaleController.php:38).CreateSaleCrmposts to the CRM withunit_block_duration(app/Actions/Sale/CreateSaleCrm.php:35), which blocks the unit at the CRM. On a CRM 200,CreateSalePortalwrites the localunit_salerow at statusDRAFT. -
Reserve — extends into the info window.
POST /api/shopper/units/{publishedUnitShopper}/{sale}/reserve(routes/api/shopper.php:103,UnitSaleController@reserveUnitSale—UnitSaleController.php:68).ReserveSaleCrmsendsupdate_info_duration(app/Actions/Sale/ReserveSaleCrm.php:31). Only on a CRM 200 does the portal setstatus = PENDING_INFOand stamppayment_method(UnitSaleController.php:85-89). The card path reaches the same point from its gateway callback — see the buy-a-unit flow. -
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@shareReservationLink—app/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. -
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@updateSale—app/Http/Controllers/Api/CrmsMiddleware/UnitSaleController.php:12). TheUpdateSaleaction (app/Actions/Sale/UpdateSale.php) maps the per-countrycrm_statusonto a localUnitSaleStatus: a cancel code becomesCANCELED(EgyptUpdateSale.php:89-91, Oman110-112, Montenegro130-132), a bounced/draft code becomesDRAFT, and a reserved/contracted code becomesDONE. A released hold therefore shows up locally asCANCELEDor back atDRAFT.
Status vocabulary
Section titled “Status vocabulary”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) |
Gotchas
Section titled “Gotchas”- 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) andupdate_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
1and both carry a// get temporarily from env variablecomment. The unit (days vs hours) is not asserted in this repo — the CRM decides. CheckUNIT_BLOCK_DURATION/UPDATE_INFO_DURATIONin the deployed env before quoting a number to anyone. - A shared reservation link holds nothing.
share-reservation-linkonly 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. canceledis never set by the shopper app. It comes only from the CRM webhook. If a local row iscanceled, 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/draftlocally. Reconcile against the CRM, do not trust the local status alone for stuck reservations.
