The data model
The whole database splits into two kinds of thing: a published local mirror of the CRM’s property inventory (read-mostly, admin-enriched) and a small set of transactional rows that record what a user did (a sale, an add-on, an EOI, a payment). The CRM owns the business truth; the portal keeps a projection plus its own workflow state. Hold that split and the schema makes sense. Verified against the current backend.
The inventory hierarchy
Section titled “The inventory hierarchy”flowchart TB C[Country]:::mirror --> D[Destination]:::mirror --> P[Project]:::mirror --> U[Unit]:::mirror P --> PP[ProjectPhase]:::local --> U UT[UnitType]:::mirror --> U U --> US[UnitSale]:::txn U --> UAS[UnitAddonSale]:::txn L[Launch]:::mirror --> EOI[EOIRequest]:::txn US --> TX[(Transaction ledger)]:::txn UAS --> TX EOI --> TX classDef mirror fill:#dff0f0,stroke:#0C7C84,color:#0b1b2b; classDef local fill:#eceff1,stroke:#5B6B7A,color:#0b1b2b; classDef txn fill:#f6e7dc,stroke:#C4622D,color:#0b1b2b;
The spine is Country → Destination → Project → Unit, with ProjectPhase as an optional grouping
under a project and UnitType classifying a unit:
| Model | Table | Belongs to | Identity |
|---|---|---|---|
Country |
countries |
— | slug, is_published — no source_id (fully local) |
Destination |
destinations |
Country (country_id) |
source_id, is_published, leads_limit, requires_payment_currency_choice |
Project |
projects |
Destination (destination_id) |
source_id, is_published, payment_api_secret_key |
ProjectPhase |
project_phases |
Project (project_id) |
local grouping — no source_id |
UnitType |
unit_types |
Country (country_id) |
source_id, SoftDeletes |
Unit |
units |
Project, UnitType, ProjectPhase (nullable) |
source_id, is_published (+ broker / sales-man variants), is_available, auto_synced |
A unit resolves its country through the whole chain —
$unit->project->destination->country — canonicalised in Util::getUnitCountrySlug()
(app/Actions/Util.php:77-80). That’s the same lookup the payment/CRM code uses to pick a gateway and
a Dynamics org.
The inventory is a published local mirror of the CRM
Section titled “The inventory is a published local mirror of the CRM”Units, unit-types, and the destination/project skeleton are a local projection of Dynamics data,
keyed by source_id (the Dynamics GUID). The lifecycle: fetch → store hidden → admin enriches →
publish.
- Extract —
ExtractUnitsCrmsends thecountryheader to the middleware, whose v9.1 OData inventory readers (GetUnits/GetUnitsWithParams/GetResaleUnits— see the CRM integration model) pull from Dynamics. - Transform —
TransformUnitsCrmresolves/creates the parentDestination+Project, maps theunit_type_id, decodes the option-set codes (finishing, construction, direction, bedrooms…), converts price to USD, and stampsauto_synced(app/Actions/Unit/TransformUnitsCrm.php:62-141). - Load —
LoadUnitsCrm.php:25-28doesUnit::updateOrCreate(['source_id' => …], $unitJson)— an upsert keyed onsource_id. A newly-created unit firesNotifyRecipientsOfNewUnits. There’s also an inbound push (POST units/{sourceId}from the middleware) as an alternative to the pull.
Because Load only writes the keys Transform produced, admin enrichment survives every re-sync:
| CRM-owned (overwritten each sync) | Local-owned (survives re-sync) |
|---|---|
price, currency, dollar_price, areas, bedrooms/bathrooms, view, direction, construction_status, finishing_status, delivery_date, is_available, is_resale, name, auto_synced, source_created_at/updated_at |
is_published (+ broker / sales-man), brochure, account_statement, masterplan_description, meta_title/description, reservation_fees, sales_offer_base_pdf_id, and the image/video/facility/masterplan relations |
The transactional rows
Section titled “The transactional rows”Only a few tables record what a user did. They all hang off a single polymorphic payment ledger,
transactions:
| Model | Table | What it is |
|---|---|---|
UnitSale |
unit_sale (singular) |
A reservation/purchase of a unit — the local mirror of a CRM sale. status (UnitSaleStatus) is the local workflow. crm_status mirrors the CRM’s status but is inconsistently typed — the sale webhook stores the raw CRM integer (UpdateSaleRequest.php:13 validates it as integer), while other paths write literal strings ('contracted', CreateLeadSalesInvoice.php:47) and some queries compare it as 'draft'/'canceled'. Lean on status; treat crm_status as untyped — see the KT tracker. |
UnitAddonSale |
unit_addon_sale |
An add-on purchase against a unit. |
EOIRequest |
eoi_requests |
A paid Expression of Interest an agent submits — see Submitting an EOI. |
Transaction |
transactions |
The polymorphic ledger (morphTo model → one of the three above). Holds payment_reference_id, status, type, amount, callback (raw gateway JSON), and status_after_success_transaction. |
UserRequest |
user_requests |
The lead/interest audit row (SaveUserRequest) — a broker lead writes one on a successful submit; it holds no lead PII. |
UnitSale / UnitAddonSale / EOIRequest each expose transactions() as a morphMany and cache
the latest on transaction_id. The payment lifecycle on that ledger is the
online payment pipeline.
State-machine catalog
Section titled “State-machine catalog”The workflows are all enum-backed. This is the map; the flow pages have the full machines.
| Enum | Backing | Values | Set where |
|---|---|---|---|
TransactionStatus |
string | pending · success · failure · canceled |
the gateway callback path |
UnitSaleStatus |
string | draft · pending_info · download_form · pending_finish · done · canceled |
the shopper-buy flow; CRM webhook derives draft/canceled/done |
ReservedSaleStatusEnums |
string | pending_odh_review · partially_reserved · reserved · closed · lost |
derived, not stored — computed for the broker “my sales” listing (UnitSaleMapper.php:40-42) |
Egypt/Oman/MontenegroCRMSaleStatusEnums |
int | Dynamics option-set codes → the two enums above | per-country mappers; UpdateSale.php on the sale webhook |
EOIPaymentStatusEnums |
int | pending_payment · paid · draft · expired · collected · refunded · requires_action |
CRM-owned — sent to the CRM (PaymentService.php:283), not persisted on the local eoi_requests row |
OmanBrokerApplicationStatusEnums |
string | the 12-state pre-registration machine | Oman broker pre-registration |
Local-mirror vs CRM-owned — and where the PII is
Section titled “Local-mirror vs CRM-owned — and where the PII is”This is the table to know for support and compliance: which rows are disposable projections, and which actually hold customer identity.
| Table | Kind | Holds PII? |
|---|---|---|
countries, destinations, projects, project_phases, unit_types, units |
Pure projections / CMS content | No |
unit_sale, unit_addon_sale |
Transactional (CRM references only — *_source_id FKs) |
Indirect (refs only) |
user_requests |
Lead/interest audit (references the user + unit-type/destination) | No inline PII |
eoi_requests |
Transactional | Yes — name, phone, email stored inline (EOIRequest.php:16-18) |
transactions |
Payment ledger | Possibly — callback holds the raw gateway payload; treat as sensitive |
