Broker commissions & sales invoices
Once a broker’s deal actually closes into a sale, the broker files a sales invoice against it to claim their commission. This page traces that write-flow end to end: the broker uploads a PDF invoice, the backend asks the CRM what the commission is worth, stores a pending invoice, and then an ODH admin approves or rejects it in Voyager. From there, the platform tracks the commission from pending to cashed. Everything below is traced to a file and line in the current backend.
What the broker does
Section titled “What the broker does”The broker opens a closed deal in the portal, attaches the sales invoice PDF, optionally sets a contractual date (only some destinations need it), and submits. The broker never types the commission amount — the backend derives it from the CRM. All the broker supplies is the document and which sale it is for.
The whole trip, in one diagram
Section titled “The whole trip, in one diagram”sequenceDiagram
actor B as Broker
participant SPA as Broker SPA (React)
participant API as Backend (Laravel)
participant MW as CRM middleware
participant ADM as ODH admin (Voyager)
B->>SPA: Open a closed deal, attach invoice PDF, Submit
SPA->>API: POST /api/broker/leads/{leadSourceId}/sales_invoices (multipart)
Note over API: can_submit_invoice gate → 403 if the broker is not enabled
API->>API: Find AnalyticsBrokerSale by source_id + lead_source_id
Note over API: no matching sale → "This Sale not exists"
API->>API: Duplicate-invoice + contractual-date guards
API->>MW: GET /brokers/{brokerSourceId}/commissions/{saleSourceId} (country header)
MW-->>API: commission_value, currency_name, first_installment_date, contract_in_progress
Note over API: create SalesInvoice status=pending, amount=commission_value, store PDF
API-->>SPA: 200 { id }
SPA->>B: Submitted — now pending review
ADM->>API: Voyager review → set status approved or rejected
Note over API: approved → observer stamps approved_at and expected_payout_date = now + destination SLA, emails broker
ADM->>API: Later, tick commission paid
Note over API: commission_payment_status = 1 → observer stamps cashed_at (analytics shows CACHED)Step by step (with the exact code)
Section titled “Step by step (with the exact code)”-
The route is broker-only and auth-gated.
POST .../leads/{leadSourceId}/sales_invoices(routes/api/broker.php:159) sits inside theuser-provider:brokers→auth:sanctum→ability:access-broker-apigroup (broker.php:23,:79). Its read sibling isGET .../leads/{leadSourceId}/sales_invoices(broker.php:158). -
A per-broker permission gate runs first.
SalesInvoiceController@createLeadSalesInvoicechecksif (! $broker->can_submit_invoice) return respondForbidden(...)(SalesInvoiceController.php:65-67).can_submit_invoiceis a boolean column onusers(defaultfalse, migration2023_09_07_080316_update_users_table_add_submit_invoice.php:16), flipped per broker by an admin — it is not role-based. -
The request is validated.
CreateLeadSalesInvoiceRequest(CreateLeadSalesInvoiceRequest.php:14-21) requirespdf_attachment(a file,max:2000KB ≈ 2 MB),sale_source_id(string), and an optionalcontractual_date(Y-m-d H:i:s). It also inheritscountry_id/country_slugfromCountry\IndexRequest(IndexRequest.php:16-27) — one of the two is required, andcountry_idis what routes the CRM call. -
The sale must already exist locally. The controller resolves the closed deal with
AnalyticsBrokerSale::where('source_id', $saleSourceId)->where('lead_source_id', $leadSourceId)(SalesInvoiceController.php:76-78). No match →AppCustomException('This Sale not exists')(:80-82). The invoice can only be filed against a sale the CRM already mirrored into the portal. -
Two business guards run before anything is written.
ValidSubmitSecondSalesInvoice->handle($sale)(:84) blocks a second invoice for the same sale — unless the destination iso-westand there are fewer than 2, and even then only after the first invoice’sfirst_installment_datehas passed (ValidSubmitSecondSalesInvoice.php:14-32).ValidSubmitSalesInvoiceWhenContractualDate->handle($sale, $contractualDate)(:85) makescontractual_datemandatory foro-westandmakadi-heights, and writes it back onto the sale when present (ValidSubmitSalesInvoiceWhenContractualDate.php:15-26). -
The commission amount comes from the CRM, not the broker.
GetCommissionForSale->handle(...)(:87-92) callsGET {CRMS_MIDDLEWARE_API_URL}/brokers/{brokerSourceId}/commissions/{saleSourceId}with acountryheader (GetCommissionForSale.php:20-29). The response is normalised byClientResponseMapper->mapFromMiddlewareToCms(...)(:96-98) intocommission_value,currency_name,first_installment_date,contract_in_progress, andcontract_in_progress_date. -
The invoice row is written as
pending.CreateLeadSalesInvoice->handle(...)(CreateLeadSalesInvoice.php:21-67) creates theSalesInvoicewithstatus = SalesInvoiceStatus::PENDING,amount = commission_value,currency = currency_name,sales_id = sale->id,destination_id, andowner_broker_id = sale->broker->id(:28-40). If the CRM sayscontract_in_progress, it flips the matchingUnitSale.crm_statustocontracted(:44-48). The uploaded PDF is stored as aDocument(slugdocument, pathbrokers/{id}/sales-invoices) (:49-51,:69-79). Note thatcommission_payment_statusis not set here — it defaults to0(UNPAID). -
Success or a clean 400. The create action wraps its body in a try/catch and returns
{ succeeded, message, data }(:53-66). The controller mapssucceeded→respondSuccess(['id' => ...])and any failure →respondBadRequestWithMsg(...)(SalesInvoiceController.php:107-115) — so a storage or mapping error surfaces to the broker as a 400 with a message, not a 500. -
ODH reviews it in Voyager (no API route). Approve / reject and mark-paid happen in the admin dashboard, not the broker API.
Dashboard/SalesInvoiceControllerexposes thestatusoptions (pending/approved/rejected) (Dashboard/SalesInvoiceController.php:16-20) and maps thecommission_payment_statusandsales_team_approvalcheckboxes toSalesInvoiceCommissionStatus::PAID/UNPAID(:28-43). -
The observer stamps the timestamps and emails the broker. On update (
SalesInvoiceObserver.php:26-58):status → approvedsetsapproved_at = now()andexpected_payout_date = now() + destination.sladays, then notifies the broker viaSalesInvoiceStatusApproved(:44-57).commission_payment_status → 1setscashed_at = now()(:30-37); flipping it back to0nullscashed_at(:39-42).
How commission status is tracked
Section titled “How commission status is tracked”There are two stored fields and one derived label. The broker reads them via
analytics/commissions (broker.php:162 → GetBrokerCommissionData), and a sales manager reads the
paginated, per-invoice view via analytics/brokers-commissions
(sales-manager.php:35 → GetBrokersCommissionsStatus).
status(SalesInvoiceStatus) — the review state of the invoice document itself.commission_payment_status(SalesInvoiceCommissionStatus) — whether the money has been cashed. Stored as0/1.commission_status(BrokerCommissionAnalyticsStatus) — a derived LATE/PENDING/CACHED label, computed in SQL, never stored (see below).
SalesInvoiceStatus — the document’s review state
Section titled “SalesInvoiceStatus — the document’s review state”Stored in SalesInvoice.status (SalesInvoiceStatus.php:5-11).
| Value | When it is set | Effect |
|---|---|---|
pending |
On creation (CreateLeadSalesInvoice.php:31) |
Awaiting ODH review. No payout date yet. |
approved |
Admin sets it in Voyager | Observer stamps approved_at + expected_payout_date (now + destination SLA) and emails the broker. |
rejected |
Admin sets it in Voyager | rejection_reason surfaces to the broker in the invoice list. No commission. |
SalesInvoiceCommissionStatus — has the money been cashed
Section titled “SalesInvoiceCommissionStatus — has the money been cashed”Stored in SalesInvoice.commission_payment_status (SalesInvoiceCommissionStatus.php:5-8).
| Value | Name | Meaning |
|---|---|---|
0 |
UNPAID |
Default at creation. Commission not yet paid out. |
1 |
PAID |
Admin ticked it paid — observer stamps cashed_at = now(). |
BrokerCommissionAnalyticsStatus — the derived analytics label
Section titled “BrokerCommissionAnalyticsStatus — the derived analytics label”Not a column. GetBrokersCommissionsStatus computes it in a SQL CASE over approved
invoices only (GetBrokersCommissionsStatus.php:40-64):
| Value | Name | Condition |
|---|---|---|
3 |
CACHED |
commission_payment_status = 1 |
1 |
LATE |
commission_payment_status = 0 and CURDATE() > expected_payout_date |
2 |
PENDING |
commission_payment_status = 0 and CURDATE() <= expected_payout_date |
The broker-facing GetBrokerCommissionData rolls the same data into pending vs cashed buckets,
counting a pending invoice as a late_deal when expected_payout_date < now
(GetBrokerCommissionData.php:79-97).
