arc42: Provider Settlement + Ride Penalty Lifecycle

Status: Draft — 2026-05-06 Feature Slug: provider-settlement Modules Affected: Penalty (new), RidePenalty (new), ProviderSettlement (new), PolygonProvider (modify)


1. Introduction & Goals

Business Purpose

Build provider-side accounting and penalty governance by introducing:

Quality Goals

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

Key Stakeholders

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

2. Constraints

Technical Constraints

Business Constraints


3. Context & Scope

In Scope

Out of Scope

Context Diagram

[Diagram]

4. Solution Strategy

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 inProgressProviderAssignmentSubSettlementBuilder; 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: draftopeninvestigatingapproved | 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

5. Building Blocks

Module Breakdown

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

Required File Coverage Checklist

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 to Concrete Class Mapping

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)

Runtime Traceability Matrix

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 → pendingapproved (if ≥ min_payout_amount) or re-attaches + auto-creates next parent (if below) (§6)
RecordManualPayoutTransactionAction approvedsettled; 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)

Policy Contracts

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().

Container Diagram

[Diagram]

6. Runtime View

Lifecycle Transition and Side Effects

[Diagram]

Investigation Flow: PolygonProvider Evidence Submission and Operations Decision

Provider Settlement Operations Lifecycle

[Diagram]

Transfer Flow: Provider-to-Provider Reassignment (With or Without Penalty)

[Diagram]

Failure Path: Invalid Transition and Notification Dispatch Failure

[Diagram]

Periodic Settlement Generation

Periodic Settlement Generation Workflow

  1. Period opening (explicit API call): Finance/Ops calls POST /settlements/generateGeneratePeriodicSettlementAction. 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.
  2. Ride 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 SubSettlementBuilderFactoryProviderAssignmentSubSettlementBuilder, which creates and persists the providerAssignment sub-settlement attached to the current pending parent. The orchestrating action then calls RecalculatePeriodicSettlementAction.
  3. Ride transfer → providerCancellation sub-settlement: TransferRideAction builds a ProviderCancellationSubSettlementCalculationData and passes it through SubSettlementBuilderFactoryProviderCancellationSubSettlementBuilder, which creates and persists the providerCancellation sub-settlement for the departing provider, attached to their current pending parent. The orchestrating action calls RecalculatePeriodicSettlementAction.
  4. RecalculatePeriodicSettlementAction (called by the orchestrating action after every sub-settlement write):
    • Sums all sub-settlement amounts under the parent.
    • Reads transaction_fee_rate from parent.transaction_fee_rate (set at period creation — not re-derived).
    • Upserts Payment Gateway fee item — 3% from config('provider-settlement.payment_gateway_fee_rate').
    • Upserts Transaction fee item — using the stored transaction_fee_rate.
    • Persists both as ProviderSettlementItem rows on the parent (upsert prevents duplicates).
  5. Penalty approved mid-period → penaltyReceivable sub-settlement: ReviewRidePenaltyDecisionAction, when decision = approved, builds ProviderPenaltyReceivableSubSettlementCalculationData and passes it through SubSettlementBuilderFactoryProviderPenaltyReceivableSubSettlementBuilder (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.
  6. Finance runs ApprovePeriodicSettlementAction:
    • Investigation guard: action first checks for any RidePenalty in investigating state for rides within the settlement period. If found, throws ProviderSettlementPenaltyInvestigationPendingException (422). Finance must resolve all outstanding investigations before proceeding.
    • Threshold check: reads provider_operating_country.min_payout_amount for the provider's operating country.
      • If total payout < threshold: the sub-settlements of this parent are re-attached to the next period's parent.
        • If the next period's parent does not exist, 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.
        • If the next period's parent already exists (Finance pre-generated it), sub-settlements are re-attached by UPDATE parent_id only.
        • No carryForward status; no carryForwardDeduction item.
      • If total payout ≥ threshold: mark parent + all sub-settlements as approved.
  7. Finance records payout via RecordManualPayoutTransactionAction: creates PayoutTransaction (method = wallet), transitions parent to settled.

Finance Approve and Payout Settlement

[Diagram]

Runtime Notes


7. Deployment View


8. Cross-Cutting Concepts

Authorization

Sensitive Data Handling

Validation

Performance Strategy

Error Handling

Observability

Test Strategy

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

9. Architecture Decisions


10. Quality Requirements

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

11. Risks & Technical Debt

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

12. Glossary


13. Database Schema

New Tables

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:

Migration 2: add_parent_and_sub_type_to_provider_settlements_table

Indexes added:

provider_settlement_items (ProviderSettlement module)

Column Type Constraints Notes