Status: Draft — 2026-05-06 Feature Slug:
provider-settlementModules Affected:Penalty(new),RidePenalty(new),ProviderSettlement(new),PolygonProvider(modify)
Build provider-side accounting and penalty governance by introducing:
PolygonProvider via
PayoutPeriodTermsEnum. Finance/Ops opens a settlement period via GeneratePeriodicSettlementAction (explicit API
call), which creates a parent ProviderSettlement in pending status. Sub-settlements accumulate within the open
period as rides and penalties occur — they are not batch-created at period generation time.providerAssignment sub-settlements attach to the current pending parent when a ride goes inProgress.providerCancellation sub-settlements are created by TransferRideAction for the departing provider.penaltyReceivable sub-settlements are created by ReviewRidePenaltyDecisionAction on penalty approval.config/provider-settlement.php, recalculated
after every sub-settlement write) and Transaction fee (rate stored on ProviderSettlement.transaction_fee_rate at
generation time: 8% for 10-day, 5% for 15-day, 0% for 30-day).provider_operating_country.min_payout_amount. When a
period's total payout is below this threshold, sub-settlements are re-attached to the next periodic settlement parent
(auto-created via GeneratePeriodicSettlementAction if absent). No carry-forward deduction item is created; no
carryForward status is used.PayoutTransaction model supporting wallet (manual) and visa (Stripe-Connect, out
of scope) methods.| Priority | Goal | Scenario |
|---|---|---|
| 1 | Reliability | Settlement totals and lifecycle transitions are deterministic and auditable |
| 2 | Suitability | Finance/operations can manage penalties and payouts without code changes |
| 3 | Maintainability | Penalty catalog, penalty lifecycle, and settlement accounting evolve independently |
| Stakeholder | Need |
|---|---|
| Operations Admin | Create penalties, progress lifecycle states, and investigate disputes quickly |
| Finance Team | Review monthly settlement composition and settle provider obligations safely |
| PolygonProvider | Receive timely lifecycle outcomes and understand payout/penalty effects |
Modules/{ModuleName}/.KernelAction pattern), not controllers.InvoiceTypeEnum.draft, open, investigating, approved, cancelled) supersedes old three-state model.draft is an internal editing state before the penalty is visible to the provider (no notification sent).open is the first provider-visible state (ops publishes via PublishRidePenaltyAction).investigating is the provider-acknowledgement phase — provider admin adds provider_notes + evidence; ops is
notified.DISPUTED state is removed — investigating covers both acknowledgement and evidence submission.approved (penalty upheld — deducted in next settlement cycle) and cancelled (penalty waived —
no deduction).CreateRidePenaltyAction → PublishRidePenaltyAction → InvestigateRidePenaltyAction →
ReviewRidePenaltyDecisionAction.PolygonProvider.is_active. Deactivation is an optional ops decision passed
as
deactivate_provider: true in ReviewRidePenaltyDecisionData — it is not triggered automatically on approved.ApprovePeriodicSettlementAction) while any RidePenalty for rides
within the settlement period is in investigating state. Finance must wait for all outstanding investigations to
reach a terminal state (approved or cancelled) before approving the period.Penalty catalog model and operations management flow.RidePenalty lifecycle and dispute workflow.ProviderSettlement periodic aggregate (10/15/30 days per BusinessPartner terms) with parent/child settlement
records, fee items, and below-threshold sub-settlement re-attachment.oldProviderCost, newProviderCost, penaltyAmount) into settlement
generation and audit trails.| Decision | Choice | Rationale |
|---|---|---|
| Module boundaries | Separate Penalty, RidePenalty, ProviderSettlement modules |
Avoid mixed responsibilities and enable independent evolution |
| Settlement architecture | Self-referencing ProviderSettlement model (parent_id) for top-level and sub-settlements, plus separate ProviderSettlementItem model |
Mirrors Invoice/InvoiceItem pattern while preserving provider accounting isolation |
| Settlement period terms | Settlement period (10 / 15 / 30 days) stored on PolygonProvider.payout_period_terms (PayoutPeriodTermsEnum) — mirrors BusinessPartner periodic billing structure |
Each provider's settlement cycle is self-contained; no runtime dependency on the BusinessPartner module |
| Top-level settlement deductions | Payment Gateway fee (3%) and Transaction fee (8% / 5% / 0% by period) are ProviderSettlementItem rows on the parent settlement |
Keeps fee logic declarative and auditable per settlement record |
| Minimum payout threshold | ApprovePeriodicSettlementAction reads provider_operating_country.min_payout_amount for the provider's operating country. If total < threshold: sub-settlements are re-attached to the next period's parent settlement (auto-created via GeneratePeriodicSettlementAction if absent). If ≥ threshold: status = approved. No carryForward status or carryForwardDeduction item is used. |
Per-country configurability without deployment; threshold may differ across markets |
| Penalty CRUD actions | Single SavePenaltyAction handles create and update; is_active = false replaces delete |
Reduces action count and keeps the entity lifecycle in one place |
| ResourceCollection | No ResourceCollection classes — index endpoints return plain JsonResource collections |
Avoids unnecessary wrapper classes; index responses follow existing API conventions |
| Settlement enum style | ProviderSettlementTypeEnum follows InvoiceTypeEnum value style (camelCase keys/values). ProviderSettlementSubTypeEnum is not used — type classification is handled by ProviderSettlementTypeEnum alone. |
Keeps enum conventions consistent across financial domains; avoids redundant sub-type enum |
| Settlement build orchestration | GeneratePeriodicSettlementAction creates the parent period shell only — no builders called at generation time. Sub-settlements are written incrementally by the action responsible for each triggering event: ride inProgress → ProviderAssignmentSubSettlementBuilder; ride transfer → ProviderCancellationSubSettlementBuilder (via TransferRideAction); penalty approved → ProviderPenaltyReceivableSubSettlementBuilder (via ReviewRidePenaltyDecisionAction). All resolved through SubSettlementBuilderFactory. |
Incremental writes reflect real-time accounting; no end-of-period sweep required; mirrors SubInvoiceBuilderFactory extensibility |
| Penalty catalog | Database-backed penalty definitions using slug, severity, percentage | Operations can manage policies without deployment |
| Lifecycle orchestration | State machine-like transition guards with event-driven side effects | Enforce consistency and auditable transitions |
| RidePenalty lifecycle | Five states: draft → open → investigating → approved | cancelled. All values lowercase. DISPUTED removed; investigating covers provider acknowledgement + evidence. SETTLED removed — settlement linkage tracked via ProviderSettlementItem. |
Simplifies state machine; investigating is sufficient for provider-side interaction |
| RidePenalty notes | provider_notes (set during InvestigateRidePenaltyAction), operation_note (set during ReviewRidePenaltyDecisionAction); activity log for full audit trail |
Role-scoped note fields + activity log replace generic dispute comments |
| Cross-module enum cast | PayoutPeriodTermsEnum defined in ProviderSettlement, cast on PolygonProvider.payout_period_terms — only permitted cross-module cast |
No Shared module overhead for a single consumer; no reverse dependency |
| Payout recording | PayoutTransaction model in ProviderSettlement module records provider payouts; method enum: wallet (manual) / visa (Stripe-Connect, out of scope) |
Mirrors PaymentTransaction on Invoice side; keeps provider-side payouts isolated from customer payments |
| Transfer accounting modes | TransferRideAction orchestrates: validates, routes to sub-actions (CreateRidePenaltyAction if penalty mode, sub-settlement builders). Mode: noPenalty / withPenalty; penalty can also be created standalone via CreateRidePenaltyAction directly. |
Separation of concerns: orchestration action owns validation + routing; sub-actions own business logic |
| Transaction fee rate storage | transaction_fee_rate stored as a decimal column on the parent ProviderSettlement record at generation time (derived from PayoutPeriodTermsEnum once). RecalculatePeriodicSettlementAction reads the stored rate from the record — never re-derives from enum. |
Rate changes on future periods only; already-generated periods are rate-stable |
| Payment Gateway fee config | 3% gateway fee sourced from config/provider-settlement.php. Upserted into ProviderSettlementItem on every RecalculatePeriodicSettlementAction run. |
Single config source; Finance can request rate change without code change to action bodies |
| Next-period parent auto-creation | When ApprovePeriodicSettlementAction needs to re-attach sub-settlements (below threshold) and the next period's parent does not exist, it calls GeneratePeriodicSettlementAction to create it. Period dates are derived from current period_end + 1 day as period_start, with length from PolygonProvider.payout_period_terms. If the next parent already exists, sub-settlements are re-attached by updating parent_id only. |
Prevents Finance from hitting a runtime error on first below-threshold approval; no manual pre-generation required |
| Provider deactivation at penalty | Deactivation (is_active = false) is an optional ops decision in ReviewRidePenaltyDecisionData.deactivate_provider (boolean, default false). When true and decision = approved, ReviewRidePenaltyDecisionAction calls DeactivatePolygonProviderAction. Not automatic. |
Gives ops explicit control; avoids auto-deactivating providers for minor penalties |
| Investigation blocks approval | ApprovePeriodicSettlementAction checks for any RidePenalty in investigating status linked to rides within the settlement period. If found, throws ProviderSettlementPenaltyInvestigationPendingException (422). Finance must resolve all pending investigations first. |
Prevents approving a settlement that may still gain penalty sub-settlements from in-flight investigations |
Modules/Penalty/
App/Models/Penalty.php
App/Actions/Dashboard/SavePenaltyAction.php
App/Shared/Data/SavePenaltyData.php
App/Shared/Data/SavePenaltyResultData.php # extends Data; ::success(Penalty) / ::failure(ResultData)
App/Shared/Enums/PenaltySeverityEnum.php
App/Exceptions/PenaltyNotFoundException.php
App/Communication/Http/Controllers/Dashboard/PenaltyController.php
App/Communication/Http/Requests/Dashboard/SavePenaltyRequest.php
App/Communication/Http/Resources/Dashboard/PenaltyResource.php
App/Communication/Http/Routes/dashboard.php
App/Policies/PenaltyPolicy.php
App/Providers/PenaltyServiceProvider.php
App/QueryBuilders/PenaltyQueryBuilder.php
database/migrations/2026_XX_XX_000001_create_penalties_table.php
database/factories/PenaltyFactory.php
tests/Feature/Dashboard/PenaltyCrudTest.php
Modules/RidePenalty/
App/Models/RidePenalty.php # fields: provider_notes (set by provider), operation_note (set by ops); activity log for audit trail
App/Shared/Enums/RidePenaltyStatusEnum.php # draft, open, investigating, approved, cancelled (all lowercase camelCase values)
App/Shared/Data/CreateRidePenaltyData.php
App/Shared/Data/CreateRidePenaltyResultData.php # extends Data; ::success(RidePenalty) / ::failure(ResultData)
App/Shared/Data/PublishRidePenaltyResultData.php # extends Data; ::success(RidePenalty) / ::failure(ResultData)
App/Shared/Data/InvestigateRidePenaltyData.php # provider_notes + evidence media
App/Shared/Data/InvestigateRidePenaltyResultData.php # extends Data; ::success(RidePenalty) / ::failure(ResultData)
App/Shared/Data/ReviewRidePenaltyDecisionData.php # operation_note + decision (approved|cancelled) + deactivate_provider (bool, default false)
App/Shared/Data/ReviewRidePenaltyDecisionResultData.php # extends Data; ::success(RidePenalty) / ::failure(ResultData)
App/Actions/CreateRidePenaltyAction.php # creates in draft; ops-only; also used within transfer context
App/Actions/PublishRidePenaltyAction.php # draft → open; notifies provider; no DTO (model from route model binding)
App/Actions/InvestigateRidePenaltyAction.php # open → investigating; provider adds notes + evidence; notifies ops
App/Actions/ReviewRidePenaltyDecisionAction.php # investigating → approved|cancelled; ops adds operation_note; if decision=approved and deactivate_provider=true, calls DeactivatePolygonProviderAction
App/Events/RidePenaltyStatusChangedEvent.php
App/Events/RidePenaltyInvestigationStartedEvent.php # dispatched by InvestigateRidePenaltyAction
App/Exceptions/RidePenaltyNotFoundException.php
App/Exceptions/RidePenaltyInvalidTransitionException.php
App/Listeners/NotifyProviderOnRidePenaltyChangeListener.php
App/Listeners/NotifyOpsOnRidePenaltyChangeListener.php
App/Listeners/NotifyOpsOnRidePenaltyInvestigationStartedListener.php
App/Notifications/ProviderRidePenaltyStatusChangedNotification.php
App/Notifications/OpsRidePenaltyStatusChangedNotification.php
App/Notifications/OpsRidePenaltyInvestigationStartedNotification.php
App/Communication/Http/Controllers/Dashboard/RidePenaltyController.php
App/Communication/Http/Controllers/Provider/RidePenaltyInvestigationController.php
App/Communication/Http/Requests/Dashboard/CreateRidePenaltyRequest.php
App/Communication/Http/Requests/Dashboard/PublishRidePenaltyRequest.php
App/Communication/Http/Requests/Dashboard/ReviewRidePenaltyDecisionRequest.php
App/Communication/Http/Requests/Provider/InvestigateRidePenaltyRequest.php # provider_notes + media upload
App/Communication/Http/Resources/Dashboard/RidePenaltyResource.php
App/Communication/Http/Resources/Provider/RidePenaltyResource.php
App/Communication/Http/Routes/dashboard.php
App/Communication/Http/Routes/provider.php
App/Policies/RidePenaltyPolicy.php
App/Providers/RidePenaltyServiceProvider.php
App/Providers/EventServiceProvider.php
App/QueryBuilders/RidePenaltyQueryBuilder.php
database/migrations/2026_XX_XX_000101_create_ride_penalties_table.php
database/factories/RidePenaltyFactory.php
tests/Feature/Dashboard/RidePenaltyLifecycleTest.php
tests/Feature/Provider/RidePenaltyInvestigationTest.php
tests/Unit/RidePenaltyStatusTransitionTest.php
Modules/ProviderSettlement/
App/Models/ProviderSettlement.php
App/Models/ProviderSettlementItem.php
App/Models/PayoutTransaction.php
App/Shared/Enums/ProviderSettlementStatusEnum.php # pending, approved, settled (no carryForward)
App/Shared/Enums/ProviderSettlementTypeEnum.php # periodicSettlement, providerAssignment, providerCancellation, penaltyReceivable
App/Shared/Enums/ProviderSettlementItemTypeEnum.php # paymentGatewayFee, transactionFee,ride, childSeat , rideExtra
App/Shared/Enums/PayoutPeriodTermsEnum.php # Ten (10 days), Fifteen (15 days), Thirty (30 days)
App/Shared/Enums/PayoutMethodEnum.php # wallet, visa
App/Actions/Interfaces/SubSettlementBuilderInterface.php
App/Actions/Interfaces/SubSettlementCalculationDataInterface.php
App/Actions/Builder/SubSettlementBuilderFactory.php
App/Actions/Builders/ProviderAssignmentSubSettlementBuilder.php
App/Actions/Builders/ProviderCancellationSubSettlementBuilder.php
App/Actions/Builders/ProviderPenaltyReceivableSubSettlementBuilder.php
App/Shared/Data/ProviderAssignmentSubSettlementCalculationData.php # passed to ProviderAssignmentSubSettlementBuilder when ride goes inProgress
App/Shared/Data/ProviderCancellationSubSettlementCalculationData.php # passed to ProviderCancellationSubSettlementBuilder by TransferRideAction
App/Shared/Data/ProviderPenaltyReceivableSubSettlementCalculationData.php # passed to ProviderPenaltyReceivableSubSettlementBuilder by ReviewRidePenaltyDecisionAction
App/Shared/Data/GeneratePeriodicSettlementData.php
App/Shared/Data/GeneratePeriodicSettlementResultData.php # extends Data; ::success(ProviderSettlement) / ::failure(ResultData)
App/Shared/Data/ApprovePeriodicSettlementData.php
App/Shared/Data/ApprovePeriodicSettlementResultData.php # extends Data; ::success(ProviderSettlement) / ::failure(ResultData)
App/Shared/Data/RecordManualPayoutTransactionData.php
App/Shared/Data/RecordManualPayoutTransactionResultData.php # extends Data; ::success(PayoutTransaction) / ::failure(ResultData)
App/Shared/Data/TransferRideResultData.php # extends Data; ::success(ProviderSettlement) / ::failure(ResultData)
App/Actions/GeneratePeriodicSettlementAction.php # creates parent ProviderSettlement in pending; sets period dates + transaction_fee_rate; no builders called; used by Finance/Ops API and by ApprovePeriodicSettlementAction for next-period auto-creation
App/Actions/TransferRideAction.php # orchestrates: validation + routing to sub-actions; creates providerCancellation sub-settlement (ProviderCancellationSubSettlementBuilder) for departing provider; creates RidePenalty (CreateRidePenaltyAction) if mode=withPenalty
App/Actions/ApplyTransferPenaltyToSettlementAction.php
App/Actions/AddSettlementItemAction.php
App/Actions/ApprovePeriodicSettlementAction.php # pending → approved (if ≥ provider-country min_payout_amount) or re-attaches sub-settlements to next parent (if below threshold); blocks if any RidePenalty for period rides is in investigating state
App/Actions/RecalculatePeriodicSettlementAction.php # re-aggregates all sub-settlement amounts into the parent; upserts Payment Gateway fee (from config/provider-settlement.php) and Transaction fee (rate from parent.transaction_fee_rate); called by orchestrating action after every sub-settlement write; mirrors UpdatePeriodicInvoiceAmountsAction
App/Actions/RecordManualPayoutTransactionAction.php # approved → settled; records wallet payout; transitions parent to settled
App/Exceptions/ProviderSettlementNotFoundException.php
App/Exceptions/ProviderSettlementAlreadyApprovedException.php # thrown by ApprovePeriodicSettlementAction when already approved/settled
App/Exceptions/ProviderSettlementAlreadySettledException.php
App/Exceptions/PayoutTransactionBelowThresholdException.php
App/Exceptions/ProviderSettlementPenaltyInvestigationPendingException.php # thrown when ApprovePeriodicSettlementAction finds open investigating RidePenalties for the period
App/Communication/Http/Controllers/Dashboard/ProviderSettlementController.php
App/Communication/Http/Controllers/Dashboard/ProviderTransferController.php
App/Communication/Http/Controllers/Dashboard/PayoutTransactionController.php
App/Communication/Http/Requests/Dashboard/GeneratePeriodicSettlementRequest.php
App/Communication/Http/Requests/Dashboard/ApprovePeriodicSettlementRequest.php
App/Communication/Http/Requests/Dashboard/TransferProviderRideRequest.php
App/Communication/Http/Requests/Dashboard/RecordManualPayoutTransactionRequest.php
App/Communication/Http/Resources/Dashboard/ProviderSettlementResource.php
App/Communication/Http/Resources/Dashboard/ProviderSubSettlementResource.php
App/Communication/Http/Resources/Dashboard/ProviderSettlementItemResource.php
App/Communication/Http/Resources/Dashboard/PayoutTransactionResource.php
App/Communication/Http/Routes/dashboard.php
App/Policies/ProviderSettlementPolicy.php
App/Policies/PayoutTransactionPolicy.php
App/Providers/ProviderSettlementServiceProvider.php
App/QueryBuilders/ProviderSettlementQueryBuilder.php
App/QueryBuilders/ProviderSettlementItemQueryBuilder.php
App/QueryBuilders/PayoutTransactionQueryBuilder.php
App/Communication/Console/GenerateProviderSettlementCommand.php
database/migrations/2026_XX_XX_000201_create_provider_settlements_table.php
# unique index on (provider_id, period_start, period_end) WHERE parent_id IS NULL — parent-level idempotency guard
# includes transaction_fee_rate decimal(5,4) column stored at generation time
database/migrations/2026_XX_XX_000202_add_parent_and_sub_type_to_provider_settlements_table.php
# unique index on (parent_id, ride_id, sub_type) — sub-settlement deduplication guard at DB level
database/migrations/2026_XX_XX_000203_create_provider_settlement_items_table.php
database/migrations/2026_XX_XX_000204_create_payout_transactions_table.php
database/factories/ProviderSettlementFactory.php
database/factories/ProviderSettlementItemFactory.php
database/factories/PayoutTransactionFactory.php
tests/Feature/Dashboard/ProviderSettlementGenerationTest.php
tests/Feature/Dashboard/ProviderSettlementMarkSettledTest.php
tests/Feature/Dashboard/RecordManualPayoutTransactionTest.php
tests/Feature/Dashboard/PayoutTransactionBelowThresholdTest.php
tests/Unit/ProviderSettlementParentingAggregationTest.php
tests/Unit/ProviderSettlementItemAggregationTest.php
tests/Unit/SubSettlementBuilderFactoryTest.php
tests/Unit/SettlementFeeCalculationTest.php
Modules/PolygonProvider/ (modifications only)
App/Models/PolygonProvider.php (reuse `is_active` for optional deactivation; add `payout_period_terms`)
App/Actions/DeactivatePolygonProviderAction.php (called by ReviewRidePenaltyDecisionAction when deactivate_provider=true)
database/migrations/2026_XX_XX_000301_add_payout_period_terms_to_polygon_providers_table.php
tests/Feature/Dashboard/ProviderDeactivationFromRidePenaltyTest.php # tests optional deactivation path only
provider_operating_country table (existing, migration only — no module change):
database/migrations/2026_XX_XX_000302_add_min_payout_amount_to_provider_operating_countries_table.php
Domain models: covered
Actions + Data objects: covered
Enums + status contracts: covered
Controllers + Requests + Resources: covered
Routes + Policies + Providers: covered
Events + Listeners + Notifications: covered
QueryBuilders: covered
Migrations + Factories: covered
Feature and Unit tests: covered
| Building Block | Concrete Class |
|---|---|
| Penalty catalog entity | Modules\Penalty\App\Models\Penalty |
| Penalty create/update | Modules\Penalty\App\Actions\Dashboard\SavePenaltyAction |
| Ride penalty ticket | Modules\RidePenalty\App\Models\RidePenalty (fields: provider_notes, operation_note; activity-logged) |
| Ride penalty — create (draft) | Modules\RidePenalty\App\Actions\CreateRidePenaltyAction (used both standalone and within transfer context) |
| Ride penalty — publish (draft→open) | Modules\RidePenalty\App\Actions\PublishRidePenaltyAction |
| Ride penalty — investigate (open→investigating) | Modules\RidePenalty\App\Actions\InvestigateRidePenaltyAction |
| Ride penalty — decision (investigating→approved|cancelled) | Modules\RidePenalty\App\Actions\ReviewRidePenaltyDecisionAction (optional deactivate_provider flag) |
| Periodic settlement parent | Modules\ProviderSettlement\App\Models\ProviderSettlement |
| Sub-settlement | Modules\ProviderSettlement\App\Models\ProviderSettlement (self relation via parent_id) |
| Settlement item (incl. fees + carry-forward) | Modules\ProviderSettlement\App\Models\ProviderSettlementItem |
| Sub-settlement builder factory | Modules\ProviderSettlement\App\Actions\Builder\SubSettlementBuilderFactory |
| Transfer accounting orchestration | Modules\ProviderSettlement\App\Actions\TransferRideAction |
| Manual payout recording | Modules\ProviderSettlement\App\Actions\RecordManualPayoutTransactionAction |
| Payout transaction record | Modules\ProviderSettlement\App\Models\PayoutTransaction |
Provider deactivation (optional, approved + flag) |
Modules\PolygonProvider\App\Actions\DeactivatePolygonProviderAction (called when deactivate_provider=true) |
| Minimum payout threshold | provider_operating_country.min_payout_amount (per-country config) |
| Settlement period terms | Modules\ProviderSettlement\App\Shared\Enums\PayoutPeriodTermsEnum (on PolygonProvider.payout_period_terms) |
| Building Block | Runtime Coverage |
|---|---|
PublishRidePenaltyAction |
draft→open transition (§6) |
InvestigateRidePenaltyAction |
open→investigating — provider notes + evidence (§6) |
ReviewRidePenaltyDecisionAction |
investigating→approved|cancelled — ops decision; optional deactivation when deactivate_provider=true (§6) |
RidePenaltyStatusChangedEvent listeners |
Notification flow to ops/provider (§6) |
RidePenaltyInvestigationStartedEvent listeners |
Notification to ops on investigation start (§6) |
GeneratePeriodicSettlementAction |
Period opening — creates parent ProviderSettlement in pending with transaction_fee_rate set; no builders called; also called by ApprovePeriodicSettlementAction for next-period auto-creation (§6) |
SubSettlementBuilderFactory + builders |
Called incrementally by: ride inProgress action (providerAssignment), TransferRideAction (providerCancellation), ReviewRidePenaltyDecisionAction (penaltyReceivable); each write followed by RecalculatePeriodicSettlementAction (§6) |
RecalculatePeriodicSettlementAction |
Re-aggregates parent totals; recalculates Payment Gateway fee (3%) + Transaction fee after every builder-created sub-settlement (§6) |
ApprovePeriodicSettlementAction |
checks investigating penalties → pending → approved (if ≥ min_payout_amount) or re-attaches + auto-creates next parent (if below) (§6) |
RecordManualPayoutTransactionAction |
approved → settled; creates PayoutTransaction (§6) |
TransferRideAction |
Provider transfer with/without penalty — validates + routes to sub-actions (§6) |
SubSettlementBuilderFactory::create() |
Builder resolution by calculation data class (§6) |
AddSettlementItemAction |
Settlement item write flow — ride amounts, penalty receivables, fee items, carry-forward items (§6) |
ProviderSettlementQueryBuilder |
Performance and query strategy constraints (§8, §10) |
All four policies extend KernalPolicy with guardName = 'admins-api'. Provider admins are admin-model records with a
provider role — no separate guard is required.
| Policy | $key |
CRUD Roles | Custom Permissions (#[CustomPermission]) |
Custom Role Mapping |
|---|---|---|---|---|
PenaltyPolicy |
penalty |
Ops: full CRUD | — (CRUD only) | — |
RidePenaltyPolicy |
ridePenalty |
Ops: view/create/update | publishRidePenalty (Ops), reviewRidePenaltyDecision (Ops), investigateRidePenalty (ProviderAdmin role) |
See custom methods below |
ProviderSettlementPolicy |
providerSettlement |
Ops/Finance: view; Ops: create | generatePeriodicSettlement (Ops/Finance), approvePeriodicSettlement (Finance) |
See custom methods below |
PayoutTransactionPolicy |
payoutTransaction |
Finance: view/create | recordManualPayoutTransaction (Finance) |
See custom method below |
Each #[CustomPermission] method is a named method on the policy class that gates the corresponding lifecycle action.
The permission seeder registers these custom permissions using getCustomRoleMapping().
POST /settlements/generate →
GeneratePeriodicSettlementAction. The action creates the parent ProviderSettlement in pending status, sets
period_start, period_end (from PolygonProvider.payout_period_terms), and stores transaction_fee_rate (8% /
5% / 0% derived from PayoutPeriodTermsEnum once at creation). No sub-settlements are created at this stage.
Application-level guard checks for an existing pending parent for the same provider_id + period_start + period_end
before creating.inProgress → providerAssignment sub-settlement: When a ride assigned to this provider goes inProgress
within the open period, the system builds a ProviderAssignmentSubSettlementCalculationData and passes it through
SubSettlementBuilderFactory → ProviderAssignmentSubSettlementBuilder, which creates and persists the
providerAssignment sub-settlement attached to the current pending parent. The orchestrating action then calls
RecalculatePeriodicSettlementAction.TransferRideAction builds a
ProviderCancellationSubSettlementCalculationData and passes it through SubSettlementBuilderFactory →
ProviderCancellationSubSettlementBuilder, which creates and persists the providerCancellation sub-settlement for
the departing provider, attached to their current pending parent. The orchestrating action calls
RecalculatePeriodicSettlementAction.RecalculatePeriodicSettlementAction (called by the orchestrating action after every sub-settlement write):transaction_fee_rate from parent.transaction_fee_rate (set at period creation — not re-derived).config('provider-settlement.payment_gateway_fee_rate').transaction_fee_rate.ProviderSettlementItem rows on the parent (upsert prevents duplicates).ReviewRidePenaltyDecisionAction, when decision
= approved, builds ProviderPenaltyReceivableSubSettlementCalculationData and passes it through
SubSettlementBuilderFactory → ProviderPenaltyReceivableSubSettlementBuilder (creates + persists sub-settlement
attached to current pending parent) → RecalculatePeriodicSettlementAction. All sub-settlement types flow through
the same builder factory path regardless of which action triggers the write.ApprovePeriodicSettlementAction:RidePenalty in investigating state for rides within the
settlement period. If found, throws ProviderSettlementPenaltyInvestigationPendingException (422). Finance must
resolve all outstanding investigations before proceeding.provider_operating_country.min_payout_amount for the provider's operating country.ApprovePeriodicSettlementAction calls
GeneratePeriodicSettlementAction to create it. period_start = current period_end + 1 day;
period_end = period_start + payout_period_terms days - 1. The new parent is created in pending
status.UPDATE parent_id only.carryForward status; no carryForwardDeduction item.approved.RecordManualPayoutTransactionAction: creates PayoutTransaction (method = wallet),
transitions parent to settled.RidePenalty entity; provider_notes text is a field on
the model.operation_note is persisted on RidePenalty during ReviewRidePenaltyDecisionAction — visible to both ops and
provider after decision.RidePenalty uses activity log for full audit trail of all status transitions, note changes, and actor identity.DISPUTED state is removed. The investigating state covers provider acknowledgement, evidence submission, and note
entry.approved or cancelled) is the terminal handoff. Only approved penalties trigger the builder
factory path → RecalculatePeriodicSettlementAction.deactivate_provider: true
in ReviewRidePenaltyDecisionData. When set, ReviewRidePenaltyDecisionAction calls
DeactivatePolygonProviderAction (sets is_active = false) inside the same DB::transaction.ApprovePeriodicSettlementAction enforces an investigation guard: if any RidePenalty for rides in the settlement
period is in investigating state, the action throws ProviderSettlementPenaltyInvestigationPendingException (422)
and Finance cannot approve until all investigations reach a terminal state.ApprovePeriodicSettlementAction (via GeneratePeriodicSettlementAction)
when a below-threshold re-attachment is needed and no next parent exists. period_start = current period_end + 1 day;
period_end = period_start + payout_period_terms days - 1. If the next parent already exists, only parent_id is
updated on re-attached sub-settlements.RecalculatePeriodicSettlementAction is called by the orchestrating action after every sub-settlement write.
The three orchestrating contexts are: (1) the ride inProgress action after creating a providerAssignment
sub-settlement, (2) TransferRideAction after creating a providerCancellation sub-settlement, and (3)
ReviewRidePenaltyDecisionAction after creating a penaltyReceivable sub-settlement on penalty approval. It is
never called from GeneratePeriodicSettlementAction (which creates only the parent shell). Exactly one caller per
sub-settlement write — no double-recalculation paths.config('provider-settlement.payment_gateway_fee_rate') — not fixed at a
single approval moment. It is recalculated each time RecalculatePeriodicSettlementAction runs and upserted (not
inserted) to prevent duplicate fee rows.ProviderSettlement.transaction_fee_rate at generation time.
RecalculatePeriodicSettlementAction reads this stored value — it does not re-derive the rate from
PayoutPeriodTermsEnum. This ensures already-generated periods are unaffected by future rate changes.pending (on parent creation) → approved (via ApprovePeriodicSettlementAction, if
total ≥ provider_operating_country.min_payout_amount) → settled (via RecordManualPayoutTransactionAction). If
total < threshold at approval time, sub-settlements are re-attached to the next period's parent; no carryForward
status exists.provider_operating_country.min_payout_amount are not paid out; the sub-settlements for that period
are re-attached to the next periodic parent (auto-created if necessary). No carry-forward item or status is created.PayoutTransaction.method is wallet for manual bank transfers; visa (Stripe-Connect) is reserved for a future
phase.GeneratePeriodicSettlementAction checks for an existing
pending parent settlement for the same provider_id + period_start + period_end before creating a new one.RidePenaltyInvalidTransitionException.parent_id, ownership, and period consistency.provider_settlement_id, amount rules, and source references.>= 0) with
explicit mode selection.approved state; RecordManualPayoutTransactionAction throws
PayoutTransactionBelowThresholdException if settlement has not been approved (e.g., sub-settlements were re-attached
to next period rather than approved).ApprovePeriodicSettlementAction throws ProviderSettlementPenaltyInvestigationPendingException (422) if any
RidePenalty linked to rides within the settlement period is in investigating state. Finance must resolve all
outstanding penalty investigations before approving a period.ReviewRidePenaltyDecisionData.deactivate_provider is an optional boolean (default false). It is only honoured
when decision = approved; if passed with decision = cancelled it is silently ignored.children and items relations to prevent N+1
queries.ProviderSettlementQueryBuilder must load: with(['children', 'children.items', 'items']). Nesting depth is always
1 (parent → sub-settlements only); recursive sub-settlement loading is not permitted.provider_id, period_start, period_end, and status columns.| Module | Test Type | Critical Scenarios |
|---|---|---|
Penalty |
Feature + validation tests | SavePenaltyAction (create + update), is_active toggle, authorization, slug uniqueness, percentage bounds |
RidePenalty |
Feature + unit tests | Allowed/blocked transitions (draft→open→investigating→approved|cancelled), optional deactivation on approved with flag, no deactivation without flag, draft visibility gate, DISPUTED state rejected, idempotent event dispatch |
ProviderSettlement |
Feature + integration tests | Periodic aggregation correctness, parent/sub-settlement integrity, fee item calculation (8%/5%/0%), sub-settlement re-attachment when < min_payout_amount, auto-creation of next period parent, investigation-block 422, settle action authorization |
ProviderSettlementItem |
Unit + integration tests | Item amount accuracy, type mapping (paymentGatewayFee, transactionFee), and source linkage integrity |
SubSettlementBuilderFactory |
Unit tests | Registry contract validation, default mapping, and builder resolution by calculation data class |
ProviderTransferAccounting |
Feature + integration tests | transfer without penalty, transfer with penalty, standalone penalty, and atomic rollback on accounting failure |
PayoutTransaction |
Feature + unit tests | RecordManualPayoutTransactionAction (wallet method), below-threshold carry-forward, payout authorization |
Penalty, RidePenalty, and ProviderSettlement ModulesPenalty Records Instead of Fixed Penalty Enumsconfig/provider-settlement.php; Transaction fee
rate stored on ProviderSettlement.transaction_fee_rate at generation time (to be documented)provider_operating_country.min_payout_amount;
ApprovePeriodicSettlementAction auto-creates next period parent when below threshold (to be documented)| Quality Attribute | Stakeholder | Scenario | Response Measure |
|---|---|---|---|
| Reliability | Operations Admin | Transition to approved must persist and trigger deterministic side effects |
0 missing side-effect events in lifecycle test suite |
| Security | Operations Admin, Finance Team | Unauthorized actors cannot transition penalties or settle statements | 100% forbidden assertions pass for protected endpoints |
| Operability | Finance Team | Finance can inspect settlement composition quickly | P95 settlement+item list query < 300ms for 10k items/provider-period |
| Performance | Finance Team | Periodic statement generation stays within operational window | P95 generation < 90s per provider-period with queued processing |
| Maintainability | Operations Admin | New penalty types can be added without deployment | Penalty CRUD supports create/edit/deactivate with no code release |
| Testability | Engineering Team | Critical paths are regression-safe | Transition matrix, settlement aggregation, and auth coverage enforced in CI |
| Transfer Integrity | Operations Admin, Finance Team | Provider reassignment writes must be atomic with settlement effects | 0 partial-write incidents in transfer integration tests |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Transition rules become fragmented across actions | Medium | High | Centralize transition policy/service and enforce transition-matrix tests |
| Settlement calculations drift from expected finance semantics (fees, below-threshold re-attachment) | Medium | High | Reconciliation tests and finance approval checks per release |
| Notification retry storms on mail instability | Low | Medium | Backoff + capped retries + alerting on retry thresholds |
| Legacy docs still describing invoice-extension direction | High | Medium | Update/retire old transfer-accounting docs in same feature cycle |
draft (internal), open (
provider-visible), investigating (provider acknowledged + evidence submitted), approved (upheld — deducted in
settlement), cancelled (waived — no deduction). All state values are lowercase camelCase. Fields: provider_notes (
set by provider during investigating), operation_note (set by ops during ReviewRidePenaltyDecisionAction). Full
audit trail via activity log.GeneratePeriodicSettlementAction (Finance/Ops API call). Sub-settlements accumulate within the open
period as rides and penalties occur. Period length from PolygonProvider.payout_period_terms (PayoutPeriodTermsEnum).
Stores transaction_fee_rate at period-opening time. Lifecycle: pending (period open, sub-settlements accumulating)
→ approved (via ApprovePeriodicSettlementAction, if total ≥ provider_operating_country.min_payout_amount) →
settled (via RecordManualPayoutTransactionAction). If total < threshold: sub-settlements are re-attached to the
next period's parent (auto-created if absent); no carryForward state exists.pending (parent created, aggregation in progress), approved (finance approved
parent + sub-settlements, ready for payout — requires no open investigating penalties), settled (payout recorded
and closed). No carryForward value — below-threshold handling uses sub-settlement re-attachment.parent_id = null) with high-level type. Uniqueness enforced at
application level on provider_id + period_start + period_end.parent_id set) classified by sub_type. Maximum nesting
depth: 1.provider_settlement_id). Includes
ride-level amounts, penalty receivables, and fee deductions (paymentGatewayFee, transactionFee).periodicSettlement (parent-level type), providerAssignment,
providerCancellation, penaltyReceivable (sub-settlement types). No ProviderSettlementSubTypeEnum exists — type
classification for both parent and sub-settlement levels uses this single enum.CreateRidePenaltyAction when mode=withPenalty, SubSettlementBuilderFactory
for sub-settlement writes). Body ≤ 30 lines; all business logic delegated to sub-actions.Ten (10 days) / Fifteen (15 days) / Thirty (30 days) — defined in
ProviderSettlement module, cast on PolygonProvider.payout_period_terms. Cross-module cast is explicitly permitted;
PolygonProvider imports this enum from ProviderSettlement.RecalculatePeriodicSettlementAction.
Rate sourced from config('provider-settlement.payment_gateway_fee_rate') (default 3%). Upserted as a
ProviderSettlementItem of type paymentGatewayFee on the parent settlement on every run.ProviderSettlement.transaction_fee_rate at
generation time (8% for 10-day, 5% for 15-day, 0% for 30-day). RecalculatePeriodicSettlementAction reads the
stored rate from the record. Stored as a ProviderSettlementItem of type transactionFee.provider_operating_country.min_payout_amount,
the sub-settlements for that period are re-attached to the next periodic parent settlement. The next parent is
auto-created by ApprovePeriodicSettlementAction (via GeneratePeriodicSettlementAction) if it does not already
exist. The original below-threshold parent is left empty. No carryForward status and no carryForwardDeduction
item are used.ProviderSettlement. Mirrors
PaymentTransaction on the Invoice side. Method wallet = manual bank transfer; method visa = Stripe-Connect (out
of scope).inProgress action (after providerAssignment), TransferRideAction (after
providerCancellation), and ReviewRidePenaltyDecisionAction (after penaltyReceivable). Never called from
GeneratePeriodicSettlementAction. Re-aggregates all sub-settlement amounts into the parent ProviderSettlement.
Upserts Payment Gateway fee (rate from config/provider-settlement.php) and Transaction fee (rate from
parent.transaction_fee_rate) on every run. Mirrors UpdatePeriodicInvoiceAmountsAction pattern.RidePenalty in investigating state for rides within the
settlement period — throws ProviderSettlementPenaltyInvestigationPendingException (422) if found. Then checks
provider_operating_country.min_payout_amount. If total ≥ threshold: transitions parent + sub-settlements to
approved. If total < threshold: auto-creates the next period parent via GeneratePeriodicSettlementAction (or
reuses it if it exists) and re-attaches sub-settlements by updating parent_id.penalties (Penalty module)| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
bigint unsigned | PK, auto-increment | |
slug |
varchar(100) | unique, not null | Machine-readable identifier |
name |
varchar(255) | not null | Human-readable label |
severity |
varchar(50) | not null | PenaltySeverityEnum value |
percentage |
decimal(5,2) | not null, check >= 0 and <= 100 | Deduction percentage |
is_active |
boolean | not null, default true | Soft-disable; replaces delete |
created_at |
timestamp | nullable | |
updated_at |
timestamp | nullable |
ride_penalties (RidePenalty module)| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
bigint unsigned | PK, auto-increment | |
ride_id |
bigint unsigned | FK rides.id, not null, index |
|
polygon_provider_id |
bigint unsigned | FK polygon_providers.id, not null, index |
|
penalty_id |
bigint unsigned | FK penalties.id, not null |
|
status |
varchar(30) | not null, default draft |
RidePenaltyStatusEnum: draft, open, investigating, approved, cancelled |
provider_notes |
text | nullable | Set by provider during InvestigateRidePenaltyAction |
operation_note |
text | nullable | Set by ops during ReviewRidePenaltyDecisionAction |
deactivate_provider |
boolean | not null, default false | Whether ops chose to deactivate the provider at approval |
created_at |
timestamp | nullable | |
updated_at |
timestamp | nullable |
provider_settlements (ProviderSettlement module — two migrations)Migration 1: create_provider_settlements_table
| Column | Type | Constraints | Notes |
|---|---|---|---|
id |
bigint unsigned | PK, auto-increment | |
polygon_provider_id |
bigint unsigned | FK polygon_providers.id, not null, index |
|
parent_id |
bigint unsigned | FK provider_settlements.id, nullable, index |
Null = top-level parent; set = sub-settlement |
type |
varchar(50) | not null | ProviderSettlementTypeEnum: periodicSettlement, providerAssignment, etc. |
sub_type |
varchar(50) | nullable | Same enum; used on sub-settlement rows |
status |
varchar(20) | not null, default pending |
ProviderSettlementStatusEnum: pending, approved, settled |
period_start |
date | nullable | Null for non-periodic sub-settlements |
period_end |
date | nullable | Null for non-periodic sub-settlements |
transaction_fee_rate |
decimal(5,4) | not null, default 0.0000 | Stored at generation time from PayoutPeriodTermsEnum; read by RecalculatePeriodicSettlementAction |
total_amount |
decimal(10,2) | not null, default 0.00 | Recalculated by RecalculatePeriodicSettlementAction after every sub-settlement write |
ride_id |
bigint unsigned | FK rides.id, nullable, index |
Set on sub-settlements linked to a specific ride |
created_at |
timestamp | nullable | |
updated_at |
timestamp | nullable |
Indexes:
unique (polygon_provider_id, period_start, period_end) WHERE parent_id IS NULL — parent-level idempotency guardMigration 2: add_parent_and_sub_type_to_provider_settlements_table
Indexes added:
unique (parent_id, ride_id, sub_type) — sub-settlement deduplication guard at DB levelprovider_settlement_items (ProviderSettlement module)| Column | Type | Constraints | Notes |
|---|