Skip to content

Reseller Payouts — Developer Guide ​

Phase 1 Sales companion. Slug reseller-payouts, middleware module:reseller-payouts, permissions reseller-payouts.*. Hard catalog dependency on Resellers. Partner directory: Resellers.

Backend layout ​

PiecePath
Modelapp/Models/ResellerCommissionEntry.php
EnumsResellerCommissionPartyEnum (reseller | owner), ResellerCommissionStatusEnum (accrued | approved | paid | void)
Serviceapp/Services/Tenant/ResellerCommissionService.php
Controllerapp/Http/Controllers/Tenant/Api/V1/ResellerCommissionEntryController.php
Resourcesapp/Http/Resources/Tenant/Api/V1/Reseller/*CommissionEntry*
Policyapp/Policies/ResellerCommissionEntryPolicy.php
Accrue listenerapp/Listeners/AccrueResellerCommission.php ← CustomerInvoiceBecamePaid
Void listenerapp/Listeners/VoidResellerCommission.php ← CustomerInvoiceLeftPaid
Transition helperapp/Support/Billing/CustomerInvoicePaidTransitions.php
Teststests/Feature/Tenant/Reseller/ResellerCommissionTest.php

Registered in AppServiceProvider (Event::listen for both Paid enter/leave events).

Accrual rules ​

ResellerCommissionService::accrueForPaidInvoice:

  1. Skip if invoice.reseller_id is null
  2. Skip if tenant lacks reseller-payouts entitlement
  3. Skip if reseller row missing
  4. Compute cuts from current reseller rates and invoice total:
text
resellerCut = round(total * (commission_rate / 100), 2)
ownerCut    = round((total - resellerCut) * (owner_commission_rate / 100), 2)
  1. ensurePartyEntry on (customer_invoice_id, party) — creates accrued rows, or revives void rows with refreshed amounts/rates (clears approve/pay/void metadata). Existing non-void rows are left unchanged (idempotent).
  2. Initial status accrued; currency from invoice (default USD)

Fully Paid only — listeners fire when status transitions into CustomerInvoiceStatusEnum::Paid (not Partial). Leaving Paid runs voidForInvoice, which bulk-voids every non-void row for that invoice (including paid). Re-paying the invoice revives those void rows. The manual void() API still rejects entries already in paid.

Workflow guards ​

MethodRule
approvestatus must be accrued
paystatus must be approved
voidnot already void; cannot void paid

Assignee / visibility scoping ​

List/stats/view (policy + query): without resellers.assign and not superadmin → party_user_id = actor or reseller’s user_id = actor. Approve/pay/void are permission-only (not scoped to assignee).

Permissions ​

reseller-payouts.view | approve | pay | void

Default map: admin (all four); manager (view + approve); staff/reseller (view).

API (tenant) ​

Base: /api/tenant/v1 — tenant-v1-reseller-payouts.md.

MethodPathPermission
GET/reseller-commission-entriesview
GET/reseller-commission-entries/statsview
GET/reseller-commission-entries/{id}view
POST/reseller-commission-entries/{id}/approveapprove
POST/reseller-commission-entries/{id}/paypay
POST/reseller-commission-entries/{id}/voidvoid

Frontend ​

Phase 1 is backend API. SPA should follow the Leads/Vendors list + action pattern under Sales when shipped (module: 'reseller-payouts', permission: reseller-payouts.view).

Tests ​

bash
php artisan test --compact tests/Feature/Tenant/Reseller/ResellerCommissionTest.php

Logging ​

Spatie LogsActivity on ResellerCommissionEntry (log name reseller_commission_entries).

Deferred ​

  • Cross-workspace identity for parties
  • Gateway disbursement / Connect payouts
  • Accrual on Partial status
  • Rate change recalculation after accrue

Official documentation for the EloSync SaaS Platform.