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.phpCustomerInvoiceBecamePaid
Void listenerapp/Listeners/VoidResellerCommission.phpCustomerInvoiceLeftPaid
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/v1tenant-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.