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.
The shape in one diagram
Section titled “The shape in one diagram”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;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:
-
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:23always sendsegypt.) -
The middleware resolves the country at container-resolution time.
AppServiceProvider::getCountryFromHeadersOrFail()(app/Providers/AppServiceProvider.php:295-304) doesCountryName::tryFrom(strtolower(header('country'))); a missing/unknown value throwsUnsupportedCountryException. That one lookup drives three scoped bindings:CountryName(:306-311), the read-pathCrmCredentials(:313-330), and the country-specific concrete client for eachGet…Interface(:347-369, throwingUnsupportedClassForCountryon an unmapped combo). -
The wire header is the slug; the numeric 1/2/3 is internal.
CountryName(app/Enums/CountryName.php:14-33) maps1 => EGYPT, 2 => OMAN, 3 => MONTENEGRO; theCrmCountryIdConverterwraps it, and the numeric id is stamped into write payloads (Dynamics expects the numeric country) — e.g.ReserveSale.phpsets$data['country'] = $countrySourceId.
Writes → Azure Logic Apps
Section titled “Writes → Azure Logic Apps”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-POSTsconfig('services.crms.link_dev_auth')withclient_id,client_secret,grant_type, andresource(:19-24) — the Azure AD v1.0 flow. The token is cached under one shared key,linkdev-crm-token(:43-46). Env groupLINK_DEV_*(config/services.php:165-171). - Endpoints. Each write op reads its Logic-App URL from a
*_URLenv (groupsunit,lead,salesman,eoi,shopper,broker, … inconfig/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.CrmClientresolves the country-scopedCrmCredentials(built atAppServiceProvider:313-330) andauthenticate()(:23-49) form-POSTs withclient_id,client_secret,grant_type, andscope(:25-33) — the Azure AD v2.0 flow. The token is cached per country (key = the slug,:18-21). Env groupsEGYPT_CRM_*/OMAN_CRM_*/MONTENEGRO_CRM_*(config/services.php:35-70), each with its ownBASE_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), plusGetOccupations(Egypt + Montenegro) andGetUnitAddons(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 Montenegroldv_unit(s)).
- v9.1 — the unit-inventory sync readers:
The finding that matters most: one Azure AD app authenticates everything
Section titled “The finding that matters most: one Azure AD app authenticates everything”The two client families at a glance
Section titled “The two client families at a glance”| 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/ClassForCountry → 500 |
reader interprets isSuccessful per response |
