Skip to content

The CRM integration model

Every flow in this platform that touches the CRM — a lead, a sale, an EOI, a broker registration — runs through the same integration model. Learn it once here and every flow’s CRM section reads as a special case of it. The one rule to hold: the crms-middleware service is the only thing that talks to the CRMs, and writes and reads take different paths. Verified against the current middleware + backend code.

flowchart TB
BE[Backend<br/>sets country header] -->|HTTP + country slug| MW[crms-middleware]
MW --> W{{write or read?}}
W -->|writes| LD[LinkDevAuthenticatedClient<br/>AAD v1.0 · resource token]
W -->|reads| CC[CrmClient per country<br/>AAD v2.0 · scope token]
LD -->|POST Logic-App URL<br/>country id 1/2/3 in body| LA{{Azure Logic Apps}}
LA --> EG[(Egypt org)]
LA --> OM[(Oman org)]
LA --> MNE[(Montenegro org)]
CC -->|GET /api/data/v9.1 or v9.2| EG
CC --> OM
CC --> MNE
AAD[Single Azure AD app registration]:::key -.->|same client id + secret| LD
AAD -.->|same client id + secret| CC
classDef key fill:#f6e7dc,stroke:#C4622D,color:#0b1b2b;
CRM integration — one header, two paths, three orgs (verified)

Country routing: the header on the wire, the id in the body

Section titled “Country routing: the header on the wire, the id in the body”

Three Dynamics 365 orgs, one per country — Egypt, Oman, Montenegro — each with its own credential group (config/services.php:35-70). A single request selects one by the country HTTP header:

  1. The backend sets country: <slug> on every middleware call — e.g. ReserveSaleCrm.php:22-24 (headers: ['country' => $countrySlug]), repeated across ~20 sibling actions. (One outlier hardcodes it: GetCitiesList.php:23 always sends egypt.)

  2. The middleware resolves the country at container-resolution time. AppServiceProvider::getCountryFromHeadersOrFail() (app/Providers/AppServiceProvider.php:295-304) does CountryName::tryFrom(strtolower(header('country'))); a missing/unknown value throws UnsupportedCountryException. That one lookup drives three scoped bindings: CountryName (:306-311), the read-path CrmCredentials (:313-330), and the country-specific concrete client for each Get…Interface (:347-369, throwing UnsupportedClassForCountry on an unmapped combo).

  3. The wire header is the slug; the numeric 1/2/3 is internal. CountryName (app/Enums/CountryName.php:14-33) maps 1 => EGYPT, 2 => OMAN, 3 => MONTENEGRO; the CrmCountryIdConverter wraps it, and the numeric id is stamped into write payloads (Dynamics expects the numeric country) — e.g. ReserveSale.php sets $data['country'] = $countrySourceId.

Every CRM write (create a lead, reserve a sale, submit an EOI, register an Oman broker) posts to an Azure Logic App HTTP endpoint — never to Dynamics OData — through LinkDevAuthenticatedClient (app/Clients/LinkDevAuthenticatedClient.php).

  • Token. authenticate() (:9-41) form-POSTs config('services.crms.link_dev_auth') with client_id, client_secret, grant_type, and resource (:19-24) — the Azure AD v1.0 flow. The token is cached under one shared key, linkdev-crm-token (:43-46). Env group LINK_DEV_* (config/services.php:165-171).
  • Endpoints. Each write op reads its Logic-App URL from a *_URL env (groups unit, lead, salesman, eoi, shopper, broker, … in config/services.php) and posts with the token — e.g. UnitClient::reserveSale()config('services.crms.unit.reserve_sale'). The Logic App fans the write out to the correct org using the numeric country in the body.
  • One attempt, 300 s, no retry. AbstractClient (app/Clients/AbstractClient.php) sets a 300-second connect+read timeout on every verb (:96-109) and has no ->retry(...) anywhere — a failed Logic-App call is attempted exactly once. If the token fetch itself fails, getAuthToken() returns '' (:23-29), the call goes out with an empty bearer, and the CRM answers 401.

Reads → per-country direct Dynamics OData

Section titled “Reads → per-country direct Dynamics OData”

Bulk inventory syncs and broker-analytics reads skip the Logic Apps and hit each Dynamics org directly over the Web API (OData), through CrmClient (app/Clients/Crms/CrmClient.php) and its per-country subclasses (EgyptCrmClient\*, OmanCrmClient\*, MontenegroCrmClient\*).

  • Per-country client_credentials. CrmClient resolves the country-scoped CrmCredentials (built at AppServiceProvider:313-330) and authenticate() (:23-49) form-POSTs with client_id, client_secret, grant_type, and scope (:25-33) — the Azure AD v2.0 flow. The token is cached per country (key = the slug, :18-21). Env groups EGYPT_CRM_* / OMAN_CRM_* / MONTENEGRO_CRM_* (config/services.php:35-70), each with its own BASE_URL.
  • The v9.1 vs v9.2 split is by resource, not country. Each reader hardcodes its API version in the URL ({baseUrl}/api/data/v9.X/<entity>); the same operation uses the same version across all three orgs (counted: 13× v9.1, 38× v9.2):
    • v9.1 — the unit-inventory sync readers: GetUnits, GetUnitsWithParams, GetResaleUnits (all three countries), plus GetOccupations (Egypt + Montenegro) and GetUnitAddons (Egypt only). E.g. EgyptCrmClient/GetUnits.php:24.
    • v9.2 — everything else: countries/currencies/nationalities/cities lookups, leads reads, installments, and all broker-analytics readers (GetBrokerLeads, GetBrokerMeetings, GetBrokerSalesCommissionDetails, …).
    • The Dynamics logical-name dialect still differs per org (Egypt/Oman new_unit(s) vs Montenegro ldv_unit(s)).

The finding that matters most: one Azure AD app authenticates everything

Section titled “The finding that matters most: one Azure AD app authenticates everything”
Write / Logic-App family Read / direct-OData family
Base client LinkDevAuthenticatedClient CrmClient
AAD flow v1.0, resource= v2.0, scope=
Token cache linkdev-crm-token (shared) country slug (per-country)
Upstream Azure Logic App HTTP triggers (*_URL env) Dynamics Web API …/api/data/v9.1|9.2
Concrete clients UnitClient, LeadClient, BrokerClient, CustomerRequestClient, SalesManCrmClient/*, LaunchesClient/*, … EgyptCrmClient/*, OmanCrmClient/*, MontenegroCrmClient/*
Retry / timeout single attempt, 300 s connect+read single attempt, 300 s connect+read
Failure shaping non-200 → 400; UnsupportedCountry/ClassForCountry500 reader interprets isSuccessful per response