Sales-Man closes a sale
The Sales-Man portal is an internal tool. An agent signs in with their Microsoft/Azure AD
work account, drives a lead through the CRM pipeline, and closes it as a sale. Unlike the shopper
flow there is almost no local mirror — the backend is a thin, authenticated pass-through to
crms-middleware, which writes every step to Microsoft Dynamics 365 via Azure Logic Apps. The
agent’s operating country rides on a country header on every write, and it selects the Dynamics
tenant, the entity, and the agent’s per-country CRM identity. Verified against current code.
Login: Microsoft SSO, token via ?token=
Section titled “Login: Microsoft SSO, token via ?token=”The login button navigates the browser to a server-side Laravel route (not an XHR):
GET /login/microsoft → Socialite azure → Azure AD → GET /login/microsoft/callback. The callback
upserts the local sales_man row, mints a Sanctum token scoped to access-sales-man-api, and
redirects to the SPA with ?token=…. The SPA reads that param and stores it in
localStorage['salesman_access_token']; every API call then sends Authorization: Bearer plus
the country header (seeded from the user’s access_countries).
The close, in one diagram
Section titled “The close, in one diagram”The “close” is a two-call climax — create the sale, then finalize it after the signed form is uploaded. Everything before (lead → pipeline → meeting → customer) exists to make the sale creatable.
sequenceDiagram
actor SM as Sales-Man
participant SPA as Sales-Man SPA
participant API as Backend /api/sales-man
participant MW as crms-middleware
participant LA as Azure Logic App
participant D365 as Dynamics 365
Note over SM,SPA: Microsoft SSO → Sanctum token, every call carries a country header
SM->>SPA: Create-sale wizard — pick unit + payment plan
SPA->>API: POST /units/{unit}/sales (country header)
Note over API: salesman-country-validation gate<br/>resolve agent's per-country CRM id
API->>MW: POST /portals/salesman/{id}/sales (client_credentials)
MW->>LA: POST {SALESMAN_CRM_API_SUBMIT_SALE_URL}
LA->>D365: create sale — new_contracting (EG/OM) · ldv_deal (MNE)
D365-->>MW: sale ids
MW-->>API: 200
API-->>SPA: sale_source_id
SM->>SPA: upload signed reservation form, confirm
SPA->>API: PATCH /units/{unit}/sales/{id} (finalize)
API->>MW: PATCH /portals/salesman/{id}/sales/{id}
MW->>LA: POST {SALESMAN_CRM_API_SUBMIT_SALE_URL} (update)
LA->>D365: update the saleThe sequence
Section titled “The sequence”- Create / qualify a lead —
POST /api/sales-man/leads(an eligibility precheck exists:.../leads/check-lead-eligible). - Create a pipeline (the opportunity) —
POST /api/sales-man/pipelines; attach a unit-type with.../pipelines/{id}/unit-types. - Schedule a meeting —
POST /api/sales-man/pipelines/{id}/meetings. - Create / attach the customer (a Dynamics
contact) —POST /api/sales-man/customers. - ★ Create the sale (the close) —
POST /api/sales-man/units/{unit}/sales(SalesController@store→CreateSale). Enriches the body withunit_source_id, re-attaches thecountryheader, and returns thesale_source_id. - Attach the signed reservation form —
POST .../sales/{saleSourceId}/attach(multipart). - Finalize the sale —
PATCH .../sales/{saleSourceId}with the reservation flags (“proceed in reservation”, “reservation form signed”).
Alt terminal: mark the pipeline lost — PATCH /api/sales-man/pipelines/{id}/lost — the deal does
not close. Every one of these goes through crms-middleware; there is no local sales table
write — the sale lives in Dynamics, and the portal only re-reads it via GET /api/sales-man/sales.
Middleware → Dynamics (the same Link-Dev tier as lead writes)
Section titled “Middleware → Dynamics (the same Link-Dev tier as lead writes)”The backend authenticates to crms-middleware with Passport client_credentials. The
middleware posts each sales-man write to a dedicated Azure Logic App (all sharing one Azure-AD
resource token, cached as linkdev-crm-token) — the sale goes to
SALESMAN_CRM_API_SUBMIT_SALE_URL. The country header is resolved to a numeric id
(egypt=1/oman=2/montenegro=3) and injected into the payload; the Logic App uses it to pick the
Dynamics tenant + entity:
| Write | Egypt | Oman | Montenegro |
|---|---|---|---|
| Sale | new_contracting |
new_contracting |
ldv_deal |
| Pipeline | ldv_interest |
ldv_interest |
ldv_pipeline |
| Customer | contact |
contact |
contact |
| Lead | lead |
lead |
lead |
Sale status: per-country integer codes → one enum
Section titled “Sale status: per-country integer codes → one enum”Dynamics returns a per-country integer status (contracting_status); the portal normalizes it to
ReservedSaleStatusEnums (PENDING_ODH_REVIEW · PARTIALLY_RESERVED · RESERVED · CLOSED · LOST) via a
per-country mapper (UnitSaleMapper → EgyptCRMSaleStatusEnums / OmanCRMSaleStatusEnums /
MonetengroCRMSaleStatusEnums). E.g. Egypt/Oman 100000003 → RESERVED, 100000004 → CLOSED,
100000007 → LOST.
Support: a sale doesn’t reach Dynamics
Section titled “Support: a sale doesn’t reach Dynamics”Work the hops in order — frontend → backend (/api/sales-man) → middleware (/portals/salesman) →
Logic App → Dynamics:
countryheader first. Missing/invalid fails at two different layers with two different statuses: the backend raisesFailedToGetCountry→ HTTP 400 (it has arender()), while the middleware’sUnsupportedCountryExceptionrenders as HTTP 500 (it passes 404 as an exception code and has norender()— a known bug, see the KT tracker). The slug must match a publishedCountry. In the UI it comes from the route:countryId; a deep-link or empty country context drops it.- Agent has no CRM identity in that country.
SalesManUtil::getSourceIdByCountryIdAndUserIdthrows when thesales_man_country_source_idrow is missing → the agent can browse but every write in that country fails. Fix: seed the per-country source id. - Backend logs.
SalesController@storelogs the request, the raw middleware response, and, on non-200,Error creating salewith the CRM message. Grep forSalesman - Sales Controller. - Auth to the middleware. A Passport
client_credentialsproblem shows as a 401 from the middleware regardless of country. - Middleware → Dynamics. Clean 200 to the middleware but no record in Dynamics → suspect the
Logic App layer (the shared
linkdev-crm-tokenor theSALESMAN_CRM_API_SUBMIT_SALE_URLapp). Check the middleware logs + the Azure Logic App run history. - Status wrong (not missing) → a mapping issue (
UnitSaleMapper+ the per-country enum), e.g. the MontenegroRESERVED=7gap above — not a delivery failure.
