Skip to content

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.

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.

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)
Sales-invoice submit + commission tracking (verified)
  1. The route is broker-only and auth-gated. POST .../leads/{leadSourceId}/sales_invoices (routes/api/broker.php:159) sits inside the user-provider:brokersauth:sanctumability:access-broker-api group (broker.php:23, :79). Its read sibling is GET .../leads/{leadSourceId}/sales_invoices (broker.php:158).

  2. A per-broker permission gate runs first. SalesInvoiceController@createLeadSalesInvoice checks if (! $broker->can_submit_invoice) return respondForbidden(...) (SalesInvoiceController.php:65-67). can_submit_invoice is a boolean column on users (default false, migration 2023_09_07_080316_update_users_table_add_submit_invoice.php:16), flipped per broker by an admin — it is not role-based.

  3. The request is validated. CreateLeadSalesInvoiceRequest (CreateLeadSalesInvoiceRequest.php:14-21) requires pdf_attachment (a file, max:2000 KB ≈ 2 MB), sale_source_id (string), and an optional contractual_date (Y-m-d H:i:s). It also inherits country_id/country_slug from Country\IndexRequest (IndexRequest.php:16-27) — one of the two is required, and country_id is what routes the CRM call.

  4. 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.

  5. Two business guards run before anything is written. ValidSubmitSecondSalesInvoice->handle($sale) (:84) blocks a second invoice for the same sale — unless the destination is o-west and there are fewer than 2, and even then only after the first invoice’s first_installment_date has passed (ValidSubmitSecondSalesInvoice.php:14-32). ValidSubmitSalesInvoiceWhenContractualDate->handle($sale, $contractualDate) (:85) makes contractual_date mandatory for o-west and makadi-heights, and writes it back onto the sale when present (ValidSubmitSalesInvoiceWhenContractualDate.php:15-26).

  6. The commission amount comes from the CRM, not the broker. GetCommissionForSale->handle(...) (:87-92) calls GET {CRMS_MIDDLEWARE_API_URL}/brokers/{brokerSourceId}/commissions/{saleSourceId} with a country header (GetCommissionForSale.php:20-29). The response is normalised by ClientResponseMapper->mapFromMiddlewareToCms(...) (:96-98) into commission_value, currency_name, first_installment_date, contract_in_progress, and contract_in_progress_date.

  7. The invoice row is written as pending. CreateLeadSalesInvoice->handle(...) (CreateLeadSalesInvoice.php:21-67) creates the SalesInvoice with status = SalesInvoiceStatus::PENDING, amount = commission_value, currency = currency_name, sales_id = sale->id, destination_id, and owner_broker_id = sale->broker->id (:28-40). If the CRM says contract_in_progress, it flips the matching UnitSale.crm_status to contracted (:44-48). The uploaded PDF is stored as a Document (slug document, path brokers/{id}/sales-invoices) (:49-51, :69-79). Note that commission_payment_status is not set here — it defaults to 0 (UNPAID).

  8. Success or a clean 400. The create action wraps its body in a try/catch and returns { succeeded, message, data } (:53-66). The controller maps succeededrespondSuccess(['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.

  9. ODH reviews it in Voyager (no API route). Approve / reject and mark-paid happen in the admin dashboard, not the broker API. Dashboard/SalesInvoiceController exposes the status options (pending/approved/rejected) (Dashboard/SalesInvoiceController.php:16-20) and maps the commission_payment_status and sales_team_approval checkboxes to SalesInvoiceCommissionStatus::PAID/UNPAID (:28-43).

  10. The observer stamps the timestamps and emails the broker. On update (SalesInvoiceObserver.php:26-58): status → approved sets approved_at = now() and expected_payout_date = now() + destination.sla days, then notifies the broker via SalesInvoiceStatusApproved (:44-57). commission_payment_status → 1 sets cashed_at = now() (:30-37); flipping it back to 0 nulls cashed_at (:39-42).

There are two stored fields and one derived label. The broker reads them via analytics/commissions (broker.php:162GetBrokerCommissionData), and a sales manager reads the paginated, per-invoice view via analytics/brokers-commissions (sales-manager.php:35GetBrokersCommissionsStatus).

  • status (SalesInvoiceStatus) — the review state of the invoice document itself.
  • commission_payment_status (SalesInvoiceCommissionStatus) — whether the money has been cashed. Stored as 0/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).