Qualifying a lead into a customer
Between “a promising lead” and “a closed sale” sits one quiet but load-bearing step: promoting a
qualified lead into a customer record, then binding that customer to the sale. In the Sales-Man
portal this is a chain of source ids — a qualified lead’s lead_source_id becomes a
customer_source_id (a Dynamics contact), which the sale then requires as
customer_source_id. Like every other sales-man write, the backend is a thin pass-through to
crms-middleware, which writes to Microsoft Dynamics 365; the operating country rides on a
country header on every call. Verified against current code.
The link that makes this a promotion, not a fresh entry
Section titled “The link that makes this a promotion, not a fresh entry”The reason “create customer” is a promotion and not a new blank record is a single required field:
CreateCustomerRequest requires lead_source_id (app/Http/Requests/SalesMan/Customer/CreateCustomerRequest.php:15).
The customer is minted from the qualified lead, carrying its identity forward. The tell that a
customer and its originating lead are the same Dynamics contact: reading a customer back
(CustomersController@show) maps the CRM payload through the lead mapper — LeadDetailsMapper::map
(app/Http/Controllers/Api/SalesMan/CustomersController.php:73).
That customer then unlocks the sale: CreateSaleRequest requires customer_source_id (alongside
pipeline_source_id and paymentplan_source_id) — app/Http/Requests/SalesMan/Country/Egypt/CreateSaleRequest.php:15-24
(Oman and Montenegro require the same trio; Montenegro also demands two sign-off dates,
Montenegro/CreateSaleRequest.php:15-26).
The chain, in one diagram
Section titled “The chain, in one diagram”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 D365 as Dynamics 365
Note over SM,SPA: Lead is already QUALIFIED in the CRM (state set CRM-side, not by the portal)
SM->>SPA: Open qualified lead - Create customer
SPA->>API: POST /customers with lead_source_id (country header)
Note over API: salesman-country-validation gate<br/>resolve agent per-country CRM source id
API->>MW: POST /salesman/{sourceId}/customers
MW->>D365: create or link contact
D365-->>MW: customer_source_id
MW-->>API: 200
API-->>SPA: customer_source_id
SM->>SPA: Create-sale wizard - pick unit + payment plan
SPA->>API: POST /units/{unit}/sales with customer_source_id + pipeline_source_id + paymentplan_source_id
Note over API: enrich body with unit_source_id from the bound Unit
API->>MW: POST /salesman/{sourceId}/sales
MW->>D365: create sale - new_contracting (EG/OM) or ldv_deal (MNE)
D365-->>MW: sale_source_id
MW-->>API: 200
API-->>SPA: sale_source_idThe sequence, with the code behind each hop
Section titled “The sequence, with the code behind each hop”Every route below lives inside the salesman-country-validation group
(routes/api/sales-man.php:88), so all of them are gated by both the Sanctum SSO token and a
valid country header. The agent’s per-country CRM identity is resolved on every write by
SalesManUtil::getSourceIdByCountryIdAndUserId (app/Actions/SalesMan/SalesManUtil.php:24-36), which
throws if the agent has no source id in that country.
-
Start from a qualified lead. Qualification is CRM-driven (see the note above). The agent picks a lead already at
QUALIFIEDand reads it viaGET /api/sales-man/leads/{leadSourceId}(sales-man.php:92→LeadController@show). -
★ Promote the lead to a customer —
POST /api/sales-man/customers(sales-man.php:109) →CustomersController@store(CustomersController.php:78-111) →CreateCustomer@handle(app/Actions/SalesMan/Customer/CreateCustomer.php:13-29). The request requireslead_source_id(CreateCustomerRequest.php:15). The action resolves the agent’s$salesManSourceId, then posts tocrms-middlewareat/salesman/{sourceId}/customerswith thecountryheader. On a CRM200the mapped body (carrying the newcustomer_source_id) is returned; otherwise the controller unwraps the CRM message and returnsrespondBadRequestWithMsg(CustomersController.php:103-110). -
(Optional) attach an ID document to the customer —
POST /api/sales-man/customers/{customerSourceId}/attach(sales-man.php:110) →CustomersController@attach→AttachCrm@handle(app/Actions/SalesMan/Customer/AttachCrm.php), a multipart upload to/salesman/{sourceId}/customers/{customerSourceId}/attach. -
Have an opportunity to sell into — the sale needs a
pipeline_source_id, created earlier viaPOST /api/sales-man/pipelines(sales-man.php:127→PipelinesController@store) with a unit-type attached atPOST /api/sales-man/pipelines/{pipelineSourceId}/unit-types(sales-man.php:135). -
★ Create the sale, bound to that customer —
POST /api/sales-man/units/{publishedUnitSalesMan}/sales(sales-man.php:102) →SalesController@store(SalesController.php:56-95) →CreateSale@handle(app/Actions/SalesMan/Sale/CreateSale.php:14-36). The request requirescustomer_source_id,pipeline_source_id,paymentplan_source_id,reservation_amount,reservation_payment_methodandselling_price(Egypt/CreateSaleRequest.php:15-24).CreateSale::prepareRequestDataenriches the body withunit_source_idfrom the route-boundUnit(CreateSale.php:38-43), then posts to/salesman/{sourceId}/sales. It returns thesale_source_id. -
Finalize the sale — attach the signed reservation form (
POST .../sales/{saleSourceId}/attach) andPATCH .../sales/{saleSourceId}. Those two “close” calls are documented on Sales-Man closes a sale.
The portal-side sale state: UnitSaleStatus
Section titled “The portal-side sale state: UnitSaleStatus”The local UnitSale model (app/Models/UnitSale.php:12, table unit_sale) is where the portal
mirrors a sale. It carries the full source-id chain built above —
sale_source_id, customer_source_id, lead_source_id, paymentplan_source_id — plus status,
which casts to the string-backed enum UnitSaleStatus (UnitSale.php:18-45,
app/Enums/UnitSaleStatus.php:7-16). This is the portal’s own sale-lifecycle state machine:
| Value | Enum | Meaning |
|---|---|---|
draft |
DRAFT |
Sale record started but not yet submitted with buyer info. |
pending_info |
PENDING_INFO |
Awaiting the customer/buyer information needed to proceed. |
download_form |
DOWNLOAD_FORM |
Info complete — the reservation form is ready to download and sign. |
pending_finish |
PENDING_FINISH |
Signed form uploaded — awaiting the final finalize step. |
done |
DONE |
Sale finalized on the portal side. |
canceled |
CANCELED |
Sale cancelled. |
Gotchas
Section titled “Gotchas”-
“Where’s the qualify endpoint?” — there isn’t one. Qualification is set in Dynamics; the portal only reads
QUALIFIEDand lets the agent create the customer. The only lead-state write the portal owns isdisqualify(sales-man.php:94). If someone reports “I can’t qualify the lead in the portal”, that is by design — the CRM owns that transition. -
countryheader first, always. Every route here is inside thesalesman-country-validationgroup (sales-man.php:88). A missing/unknowncountryheader fails at the country gate (app/Http/Middleware/ValidateSalesManCountry.php→FailedToGetCountry) before the controller runs. This is the #1 sales-man support cause across all these flows. -
Agent has no CRM source id in that country.
SalesManUtil::getSourceIdByCountryIdAndUserIdthrows when thesales_man_country_source_idrow is missing (SalesManUtil.php:24-36) — so the agent can browse but every customer/sale write in that country fails. Fix: seed the per-country source id. -
lead_source_idis required to create a customer. Omitting it is a validation failure (CreateCustomerRequest.php:15) — you cannot mint a bare customer with no originating lead through this endpoint. The customer inherits the lead’s Dynamics contact identity. -
The sale needs three source ids, not one.
customer_source_idandpipeline_source_idandpaymentplan_source_idare all required (Egypt/CreateSaleRequest.php:15-24). A common failure is having the customer but no pipeline/opportunity yet — create the pipeline (sales-man.php:127) and attach its unit-type first. -
A
200is not proof it reached Dynamics. Because both writes are pass-through, a clean backend200only means the middleware accepted the call. If the customer or sale is missing in Dynamics later, suspect the middleware → Logic App → Dynamics hops and check the middleware logs plus the Azure Logic App run history — the same triage as Sales-Man closes a sale.
