Skip to content

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.

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” 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 sale
Sales-Man close — write path to Dynamics
  1. Create / qualify a leadPOST /api/sales-man/leads (an eligibility precheck exists: .../leads/check-lead-eligible).
  2. Create a pipeline (the opportunity) — POST /api/sales-man/pipelines; attach a unit-type with .../pipelines/{id}/unit-types.
  3. Schedule a meetingPOST /api/sales-man/pipelines/{id}/meetings.
  4. Create / attach the customer (a Dynamics contact) — POST /api/sales-man/customers.
  5. ★ Create the sale (the close)POST /api/sales-man/units/{unit}/sales (SalesController@storeCreateSale). Enriches the body with unit_source_id, re-attaches the country header, and returns the sale_source_id.
  6. Attach the signed reservation formPOST .../sales/{saleSourceId}/attach (multipart).
  7. Finalize the salePATCH .../sales/{saleSourceId} with the reservation flags (“proceed in reservation”, “reservation form signed”).

Alt terminal: mark the pipeline lostPATCH /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.

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 (UnitSaleMapperEgyptCRMSaleStatusEnums / OmanCRMSaleStatusEnums / MonetengroCRMSaleStatusEnums). E.g. Egypt/Oman 100000003 → RESERVED, 100000004 → CLOSED, 100000007 → LOST.

Work the hops in order — frontend → backend (/api/sales-man) → middleware (/portals/salesman) → Logic App → Dynamics:

  1. country header first. Missing/invalid fails at two different layers with two different statuses: the backend raises FailedToGetCountryHTTP 400 (it has a render()), while the middleware’s UnsupportedCountryException renders as HTTP 500 (it passes 404 as an exception code and has no render() — a known bug, see the KT tracker). The slug must match a published Country. In the UI it comes from the route :countryId; a deep-link or empty country context drops it.
  2. Agent has no CRM identity in that country. SalesManUtil::getSourceIdByCountryIdAndUserId throws when the sales_man_country_source_id row is missing → the agent can browse but every write in that country fails. Fix: seed the per-country source id.
  3. Backend logs. SalesController@store logs the request, the raw middleware response, and, on non-200, Error creating sale with the CRM message. Grep for Salesman - Sales Controller.
  4. Auth to the middleware. A Passport client_credentials problem shows as a 401 from the middleware regardless of country.
  5. Middleware → Dynamics. Clean 200 to the middleware but no record in Dynamics → suspect the Logic App layer (the shared linkdev-crm-token or the SALESMAN_CRM_API_SUBMIT_SALE_URL app). Check the middleware logs + the Azure Logic App run history.
  6. Status wrong (not missing) → a mapping issue (UnitSaleMapper + the per-country enum), e.g. the Montenegro RESERVED=7 gap above — not a delivery failure.