Skip to content

Shopper buys a unit

Buying a unit is a reservation flow: the shopper picks a unit, pays a reservation fee (card) or records a non-card reservation, submits their info, signs the reservation form, and the sale is finished. The authoritative sale lives in the CRM (via crms-middleware); the portal keeps a local unit_sale mirror row whose status tracks progress. Verified against current backend + orascom-shopper-app code.

The shopper browses Available Units — the local published mirror of CRM inventory (filter by country, destination, bedrooms), each card showing the real price/area/beds:

Shopper app — Available Units listing: filter bar (country, destination, bedrooms, resale) and unit cards with price, bedrooms and area, from the local published mirror.

Opening a unit shows its detail (areas, gallery, payment plan) and — at the bottom — the actions that kick off the flows in these docs: Reserve unit (this flow), Download Sales Offer (the PDF flow), and I’m interested:

Shopper app — unit detail actions: the gold “Reserve unit” button, “I’m interested”, and the “Download Sales Offer” link that triggers the async PDF pipeline.

Clicking Reserve unit is what creates the unit_sale row and starts the state machine below.

Everything hangs off UnitSaleStatus (app/Enums/UnitSaleStatus.php), stored on unit_sale.status:

stateDiagram-v2
[*] --> DRAFT: create sale (blocks the unit at the CRM)
DRAFT --> PENDING_INFO: reserve (non-card)
DRAFT --> PENDING_INFO: pay by card → gateway callback
PENDING_INFO --> DOWNLOAD_FORM: submit buyer info
DOWNLOAD_FORM --> PENDING_FINISH: generate reservation form
PENDING_FINISH --> DONE: finish sale
DRAFT --> CANCELED: CRM webhook (block expired / canceled)
PENDING_INFO --> CANCELED: CRM webhook
note right of CANCELED
  CRM-owned only — the shopper
  app never sets CANCELED
end note
UnitSaleStatus — the purchase state machine
  1. Browse & open a unitGET /api/shopper/units then GET /api/shopper/units/{unit} (public). Both read the local published mirror (no CRM call).

  2. View payment termsGET /api/shopper/units/{unit}/payment-terms → a live CRM read of the real per-country payment plans.

  3. Create the sale (blocks the unit)POST /api/shopper/units/{unit}/sale (UnitSaleController@createUnitSale). CreateSaleCrm posts to the CRM with unit_block_duration, which blocks the unit at the CRM; on success CreateSalePortal writes the local unit_sale row at status DRAFT (storing sale_source_id, customer_source_id, paymentplan_source_id).

  4. Reserve — non-card OR card:

    • Non-cardPOST .../{sale}/reserve (ReserveSaleCrm, sends update_info_duration) → on CRM 200, status PENDING_INFO.
    • CardPOST .../{sale}/payment returns a gateway session (Paymob/Thawani/WSPG by the unit’s country). The SPA redirects to the gateway; the CRM reserve happens server-side in the callback (next section). The {salePayment} binding is DRAFT-only — retrying payment after the sale advanced returns 404.
  5. Submit buyer infoPOST .../{sale}/update-info (UpdateInfoCrm) → status DOWNLOAD_FORM.

  6. Generate the reservation formPOST .../{sale}/reservation-form → status PENDING_FINISH; streams the PDF back.

  7. Upload the signed formPOST .../{sale}/attach (multipart to the CRM). No local status change — the advance to PENDING_FINISH was owned by step 6.

  8. Finish the salePATCH .../{sale}/finish-sale (UpdateSalePortal) → status DONE + closed_at_dm; notifies the shopper. Installments are then readable live via GET /api/shopper/sales/{saleSourceId}/installments.

The card-payment path (where it re-enters)

Section titled “The card-payment path (where it re-enters)”
sequenceDiagram
participant SPA as Shopper SPA
participant API as Backend
participant GW as Gateway (Paymob/Thawani/WSPG)
SPA->>API: POST /api/shopper/units/{unit}/{sale}/payment
API->>GW: create order/session (by unit country)
API-->>SPA: payment key/session
SPA->>GW: redirect to gateway iframe, pay
GW-->>API: success callback (POST/GET /{gw}/callback)
Note over API: mark Transaction SUCCESS →<br/>reserveUnitCrm() → status PENDING_INFO
API-->>SPA: redirect /reserve-unit/{unit}?reserve-unit=true
Card reservation — the callback re-enters the flow

Gateway by the unit’s country (PaymentSale): Egypt → Paymob · Oman → Thawani · Montenegro → WSPG. The callback (PaymentService::handleSuccessTransactionCallback) marks the transaction SUCCESS, stamps reservation_amount + payment_method=CREDIT_DEBIT_CARD, then calls the same ReserveSaleCrm the non-card path uses, landing on PENDING_INFO, and redirects to /reserve-unit/{unit}?reserve-unit=true (or false if the CRM reserve failed after a successful charge — a manual-reconcile case). The full payment mechanics — the three gateways, the callback model, the transactions ledger, and reconciliation — are on the online payment pipeline page.

The unit-block TTL is the CRM’s, not ours

Section titled “The unit-block TTL is the CRM’s, not ours”

The portal forwards UNIT_BLOCK_DURATION on create and UPDATE_INFO_DURATION on reserve — and does nothing else with them. There is no local expiry job. The CRM holds the block and, when it expires or is cancelled, syncs the sale back (DRAFT/CANCELED) via the PATCH /api/crms-middleware/sales/{id} webhook (UpdateSale). So “the unit is no longer available mid-flow” means the CRM released the block — there’s no local timer to inspect.

Local unit_sale row (a projection) CRM-owned (source of truth)
status, crm_status, sale_source_id, customer_source_id, reservation_amount, payment_method, Transactions the sale entity, the customer, payment plan, the unit block/expiry, the reservation-form PDF, installments, the canonical status

The CRM can override the local status back via the webhook — the only path that produces CANCELED and the authoritative DONE/DRAFT reconciliation.

Read the local row and compare status vs crm_status:

Symptom Cause Where to look
status=draft but shopper “paid” Card callback didn’t complete the CRM reserve The sale’s Transactions + the handleSuccessTransactionCallback / reserveUnitCrm logs; if ?reserve-unit=false on landing, the CRM reserve failed after a successful charge → reconcile against the CRM
pending_info not advancing update-info CRM call failing (only advances on CRM 200) The logged mappedResponse on updateInfo
download_form not advancing reservation-form CRM call not returning 200 Its error = json_decode(response.body).message
canceled unexpectedly CRM pushed a cancel via the sales/{id} webhook UpdateSale per-country cancel-code mapping — CRM-driven, not a portal bug

Common failure points: CRM auth token (CrmMiddlewareClient), a wrong destination→country slug (routes to the wrong CRM), missing payment-gateway config keys (PaymentSale returns null → “failed to get payment key”), and — for currency-choice destinations — a missing currency on the sale (→ 400).