# Monthly Incentive Scheme v2 (per-user × per-product)

**Status:** Approved / Ready to implement
**Date:** 2026-08-19

## Overview

Replace the current per-sale slab incentive (`SaleIncentiveService` + `sale_incentive_slabs` + `lead_sales.incentive*`) with a **monthly, per-user, per-product** tiered incentive paid on **fully-paid sales**, bucketed by the month of the sale's **last payment**, finalized after a configurable buffer (default 15 days, from the `rules` table). Data surfaces in a new **Finance → Incentive** area.

## Confirmed decisions

- **Rate:** tiered brackets per `(user, product)`, matched on unit price (individual sale filter) + quantity or amount range checked against the excess after the eligibility threshold is deducted.
- **Split:** creator 50% / assignee 50% (100% to creator when assignee is null or same). Each user evaluates eligibility and brackets independently against **their own split share**.
- **Eligibility threshold:** per `(user, product)` only — optional `min_unit_price` AND (`min_quantity` AND/OR `min_total_amount`); all set criteria must pass. Sales consumed to reach the threshold are excluded from bracket matching — only the excess is used.
- **Brackets:** multiple brackets can match simultaneously per product per month (one per non-overlapping price/range combination); incentive is summed. Dated brackets (`valid_from`/`valid_until`) take precedence over dateless ones.
- **Payout:** product line incentive = Σ across all matching brackets (`bracket_% × bracket-filtered excess_amount`); user-month total = Σ across products.
- **Storage:** aggregate `monthly_incentives` (per user + month, carries total incentive + workflow status only) + per-product lines in `monthly_incentive_items` + bracket matches in `monthly_incentive_item_brackets` + sales tagging in `monthly_incentive_item_eligibility_sales` and `monthly_incentive_item_bracket_sales`.
- **Audit trail:** every computation tags which eligibility threshold was applied, which brackets matched, and which individual sales were used at each stage.
- **Legacy:** decommissioned (per user: "don't need the legacy amount"). New data shown in Finance → Incentive tab.
- **Permissions:** new `incentive_management` module.
- **Config scope:** per-user, per-product, set by an admin via endpoint. No bulk import, no global default set.
- **Month storage:** `month` as first-of-month DATE (e.g. `2026-06-01`); unique `(user_id, month)`.
- **Finalization:** auto-reopen on reversal — see below.

## 1. Data model (7 new migrations)

### `monthly_incentive_brackets` — tiers per (user × product)

Each bracket matches on **unit price** (filtering which individual sales are eligible) and either a **quantity range** or an **amount range** (checked against the total of filtered sales after the eligibility threshold has been deducted). Only one of the two range types needs to be set per bracket.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK → users | index `(user_id, product_id, status)` |
| `product_id` | FK → products | |
| `min_unit_price` | decimal(20,4) nullable | individual sale's `price` must be >= this to count toward this bracket |
| `max_unit_price` | decimal(20,4) nullable | individual sale's `price` must be <= this to count; null = no upper cap |
| `min_quantity` | decimal(20,4) nullable | lower bound of excess quantity range (inclusive) |
| `max_quantity` | decimal(20,4) nullable | upper bound of excess quantity range (inclusive); null = open-ended |
| `min_amount` | decimal(20,4) nullable | lower bound of excess amount range (inclusive) |
| `max_amount` | decimal(20,4) nullable | upper bound of excess amount range (inclusive); null = open-ended |
| `incentive_percentage` | decimal(10,4) | applied to the split-share amount of the excess filtered sales |
| `valid_from` | date nullable | start of dated bracket period; null = dateless (always active) |
| `valid_until` | date nullable | end of dated bracket period (inclusive); null = no end cap |
| `created_by` | FK → users | |
| `status` | bool | active flag (default 1) |
| `is_deleted` | bool | soft-delete flag (default 0) |
| timestamps | | |

Constraint: at least one of (`min_quantity`/`max_quantity`) or (`min_amount`/`max_amount`) must be set. Dated brackets (`valid_from`/`valid_until`) take precedence over dateless ones when their period covers the month being computed.

### `monthly_incentive_eligibilities` — gate per (user × product)

The eligibility threshold is a floor the user must cross before any bracket is evaluated. It filters individual sales by unit price, then checks whether the resulting totals meet the minimum. Sales consumed to reach the threshold are excluded from bracket matching — only the **excess** beyond the threshold is passed to brackets.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK → users | unique `(user_id, product_id)` |
| `product_id` | FK → products | |
| `min_unit_price` | decimal(20,4) nullable | individual sale's `price` must be >= this to count toward the threshold |
| `min_quantity` | decimal(20,4) nullable | total filtered quantity must be >= this to be eligible |
| `min_total_amount` | decimal(20,4) nullable | total filtered split-share amount must be >= this to be eligible |
| `is_active` | bool | default 1 |
| timestamps | | |

Validation: at least one of `min_quantity` or `min_total_amount` must be set. Both can be set together — all set criteria must pass. `min_unit_price` is optional independently.

### `monthly_incentives` — aggregate per (user × month)

Carries only product-agnostic data. Per-product quantities and amounts live in `monthly_incentive_items` — collapsing them here across different products with different units would be meaningless.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `user_id` | FK → users | unique `(user_id, month)` |
| `month` | date | first-of-month, e.g. `2026-06-01` |
| `eligible` | bool | true if any product line is eligible |
| `incentive_amount` | decimal(20,4) | Σ of `monthly_incentive_items.incentive_amount` |
| `status` | enum: pending/approved/paid | default `pending` |
| `finalized_at` | datetime nullable | stamped once the month's buffer passed |
| `computed_at` | datetime | last computation time |
| timestamps | | |

Index `(month, status)`.

### `monthly_incentive_items` — lines per (user × product × month)

One row per product the user had paid sales for in that month. Stores the eligibility result, excess values passed to brackets, and the total incentive for that product line. Per-sale detail lives in the child sales tables.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `monthly_incentive_id` | FK → monthly_incentives | unique `(monthly_incentive_id, product_id)` |
| `user_id` | FK → users | |
| `product_id` | FK → products | |
| `month` | date | first-of-month |
| `eligibility_id` | FK → monthly_incentive_eligibilities nullable | which threshold row was applied; null if none configured |
| `eligible` | bool | eligibility gate result |
| `excess_quantity` | decimal(20,4) nullable | filtered quantity minus threshold; passed to bracket matching |
| `excess_amount` | decimal(20,4) nullable | filtered amount minus threshold; passed to bracket matching |
| `incentive_amount` | decimal(20,4) | Σ of `monthly_incentive_item_brackets.incentive_amount` |
| `computed_at` | datetime | last computation time |
| timestamps | | |

### `monthly_incentive_item_eligibility_sales` — sales used for threshold check

Tags every individual sale that was filtered and summed to evaluate the eligibility threshold for a product line. Provides a full audit trail of which sales crossed (or failed to cross) the floor.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `monthly_incentive_item_id` | FK → monthly_incentive_items | index |
| `lead_sale_id` | FK → lead_sales | |
| `share` | decimal(5,4) | user's share ratio (0.5 or 1.0) |
| `share_quantity` | decimal(20,4) | `quantity × share` |
| `share_amount` | decimal(20,4) | `total × share` |
| timestamps | | |

### `monthly_incentive_item_brackets` — brackets that matched for a product line

One row per bracket that matched during computation. Snapshots the percentage and excess values at computation time so historical records stay accurate even if the bracket is later edited or deactivated.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `monthly_incentive_item_id` | FK → monthly_incentive_items | index |
| `bracket_id` | FK → monthly_incentive_brackets | which bracket matched |
| `excess_quantity` | decimal(20,4) nullable | bracket-filtered excess quantity used |
| `excess_amount` | decimal(20,4) | bracket-filtered excess amount used |
| `incentive_percentage` | decimal(10,4) | snapshot of percentage at computation time |
| `incentive_amount` | decimal(20,4) | `incentive_percentage × excess_amount` for this bracket |
| timestamps | | |

### `monthly_incentive_item_bracket_sales` — sales counted for a specific bracket

Tags every individual sale that passed a bracket's unit price filter and contributed to its excess and incentive calculation.

| Column | Type | Notes |
|---|---|---|
| `id` | bigint PK | |
| `monthly_incentive_item_bracket_id` | FK → monthly_incentive_item_brackets | index |
| `lead_sale_id` | FK → lead_sales | |
| `share` | decimal(5,4) | user's share ratio (0.5 or 1.0) |
| `share_quantity` | decimal(20,4) | `quantity × share` |
| `share_amount` | decimal(20,4) | `total × share` |
| timestamps | | |

## 2. Module settings (incentive group)

Per-module settings table `incentive_settings` (following the module-wise pattern used by the
meeting/credit/notification modules) with its own `IncentiveSetting` model + `IncentiveSettingCatalog`:

| Key | Type | Default | Description |
|---|---|---|---|
| `monthly_buffer_days` | integer | `15` | finalization delay after month-end |
| `monthly_enabled` | boolean | `1` | global on/off kill switch for the scheduler |

`IncentiveSettingSeeder::sync()` seeds these automatically and they appear through the incentive
module's own `/finance/incentive/setting/*` endpoints.

## 3. Attribution, bucketing & matching

### Attribution & split

- Sale's users = `[{created_by, 0.5}, {assigned_to, 0.5}]` if different, else `[{created_by, 1.0}]` — same logic as `LeadSaleController.php:465-487`.
- Only `payment_status = 'paid'` and `is_deleted = 0` sales count.
- Each user independently evaluates eligibility and brackets against **their own split share** of the filtered sales.

### Month bucket

- **Month bucket** = month of the sale's **last payment** (`max(payment_date)` among non-deleted `sale_payments`). A sale whose last payment falls in June counts toward June regardless of sale date.

### Eligibility check (per user × product)

If a `monthly_incentive_eligibilities` row exists and `is_active = true` for `(user, product)`:

1. **Filter sales** — from the user's monthly paid sales for that product, keep only those where `sale.price >= min_unit_price` (if `min_unit_price` set).
2. **Tag filtered sales** — persist each filtered sale to `monthly_incentive_item_eligibility_sales` with its `share`, `share_quantity`, and `share_amount`.
3. **Sum filtered totals** — `filtered_quantity = Σ share_quantity`, `filtered_amount = Σ share_amount` from the tagged rows.
4. **Check thresholds** — all set criteria must pass:
   - if `min_quantity` set → `filtered_quantity >= min_quantity`
   - if `min_total_amount` set → `filtered_amount >= min_total_amount`
5. If any criterion fails → **not eligible**; set `monthly_incentive_items.eligible = false`, `incentive_amount = 0`, stamp `eligibility_id`. No bracket matching runs.

### Excess computation (threshold deduction)

When eligible, only the **excess** beyond the threshold enters brackets. Excess is computed from the eligibility-filtered totals — not tied to specific individual sales:

- `excess_quantity = filtered_quantity - min_quantity` (if `min_quantity` set, else `filtered_quantity`)
- `excess_amount = filtered_amount - min_total_amount` (if `min_total_amount` set, else `filtered_amount`)

Store `excess_quantity` and `excess_amount` on `monthly_incentive_items` alongside `eligibility_id`.

### Bracket matching (per user × product, evaluated against excess)

All active brackets for `(user, product)` whose time period covers the month are evaluated independently. **Multiple brackets can match simultaneously** and incentive is summed across all of them.

For each bracket:

1. **Dated bracket precedence** — if any bracket has `valid_from`/`valid_until` covering the month, it takes precedence over dateless brackets. Dateless brackets are skipped when a dated bracket for the same `(user, product)` covers the month.
2. **Filter sales by unit price** — keep only the user's monthly paid sales for that product where `sale.price >= min_unit_price` (and `<= max_unit_price` if set). Independent filter per bracket.
3. **Tag bracket sales** — persist each passing sale to `monthly_incentive_item_bracket_sales` (under the `monthly_incentive_item_brackets` row being built) with its `share`, `share_quantity`, and `share_amount`.
4. **Compute bracket-filtered excess** — sum `share_quantity` and `share_amount` from the tagged bracket sales, then deduct the eligibility threshold proportionally → bracket-specific `excess_quantity` and `excess_amount`.
5. **Check range** — all set range criteria must pass:
   - if quantity range set → `excess_quantity >= min_quantity && (max_quantity null || excess_quantity <= max_quantity)`
   - if amount range set → `excess_amount >= min_amount && (max_amount null || excess_amount <= max_amount)`
6. If match → persist a `monthly_incentive_item_brackets` row with `bracket_id`, `excess_quantity`, `excess_amount`, snapshotted `incentive_percentage`, and `incentive_amount = incentive_percentage × excess_amount`.
7. If no match → no row written for this bracket.

Product line `incentive_amount` = Σ `monthly_incentive_item_brackets.incentive_amount`.
Aggregate `incentive_amount` = Σ product line `incentive_amount`.
Aggregate `eligible` = any product line `eligible = true`.

## 4. `MonthlyIncentiveService`

### Attribution & filtering helpers

- `usersForSale(LeadSale): Collection` — returns share pairs `[{user_id, share}]`; 50/50 split when creator ≠ assignee, else 100% to creator.
- `monthBucket(LeadSale): ?Carbon` — last-payment month start (null if no payments).
- `monthlyPaidSalesQuery(int $userId, Carbon $monthStart, Carbon $monthEnd): Builder` — paid, non-deleted sales where user is creator or assignee, within the month.
- `filterSalesByUnitPrice(Collection $sales, ?float $minUnitPrice, ?float $maxUnitPrice): Collection` — filters individual sales by `sale.price` range; used independently for eligibility and each bracket.
- `computeShareTotals(Collection $sales, float $share): array` — returns `{quantity, amount}` as split-share sums (`Σ quantity × share`, `Σ total × share`) over the given sale collection.
- `computeExcess(float $total, ?float $threshold): float` — returns `max(0, total - threshold)`; returns `total` if threshold is null.

### Eligibility

- `checkEligibility(MonthlyIncentiveItem $item, Collection $monthlySales, float $share): ?array` — filters sales by eligibility `min_unit_price`, persists tagged sales to `monthly_incentive_item_eligibility_sales`, sums totals, checks `min_quantity`/`min_total_amount` thresholds. Returns `null` if no active eligibility row exists for `(user, product)` or thresholds not met. Otherwise returns `{eligibility_id, excess_quantity, excess_amount}` and stamps them onto the item.

### Bracket matching

- `activeBracketsForMonth(int $userId, int $productId, Carbon $month): Collection` — fetches active non-deleted brackets for `(user, product)`; when any dated bracket's `valid_from`/`valid_until` covers the month, dateless brackets are excluded from the result.
- `evaluateBracket(MonthlyIncentiveItem $item, MonthlyIncentiveBracket $bracket, Collection $monthlySales, float $share, array $eligibilityExcess): ?MonthlyIncentiveItemBracket` — applies bracket's own unit price filter, persists tagged sales to `monthly_incentive_item_bracket_sales`, computes bracket-filtered excess (deducting eligibility threshold proportionally), checks quantity/amount range. Returns null if no match. On match, persists a `monthly_incentive_item_brackets` row with snapshotted `incentive_percentage` and computed `incentive_amount = incentive_percentage × excess_amount`.
- `matchAllBrackets(MonthlyIncentiveItem $item, Collection $monthlySales, float $share, array $eligibilityExcess): Collection` — calls `evaluateBracket` for each active bracket; returns the collection of persisted `MonthlyIncentiveItemBracket` rows.

### Product line & aggregate computation

- `computeProductLine(int $userId, int $productId, Carbon $month, Collection $monthlySales, float $share): MonthlyIncentiveItem` — creates/replaces the `monthly_incentive_items` row; runs `checkEligibility`, then `matchAllBrackets`; sets `incentive_amount = Σ bracket incentive_amounts`; returns the persisted item.
- `computeUserMonth(int $userId, Carbon $month): MonthlyIncentive` — loads all products the user has paid sales for in the month; calls `computeProductLine` for each inside a DB transaction; upserts the `monthly_incentives` aggregate (`incentive_amount = Σ items`, `eligible = any item eligible`, stamps `computed_at`); returns the aggregate.
- `refreshUserMonth(int $userId, Carbon $month, bool $reopen = false): void` — if month is finalized and `reopen=false` → skip. If `reopen=true` → clears `finalized_at`, sets status `pending`. Deletes existing items + child rows for the user-month, then calls `computeUserMonth`. If `reopen=true` → immediately re-stamps `finalized_at` (buffer already passed).
- `finalizeMonth(Carbon $month): void` — calls `computeUserMonth` for all users with paid sales in the month; stamps `finalized_at` on each aggregate.
- `recalculateMonth(Carbon $month): void` — admin force-recompute; calls `refreshUserMonth(..., reopen=true)` for all users in the month; re-stamps `finalized_at`.
- `isMonthFinalizable(Carbon $month): bool` — `now() >= monthEnd + IncentiveSetting::get('monthly_buffer_days')`.

## 5. Jobs & scheduler

### `CalculateMonthlyIncentives` (synchronous, scheduled)
- `routes/console.php`, daily `01:30`, `->name('calculate-monthly-incentives')->withoutOverlapping()`.
- Skips work when `incentive.monthly_enabled` is off.
- For every past month whose finalization date has passed and that has no finalized rows → `finalizeMonth()` (refreshes the just-finalized previous month and catches up any missed months).

### `RefreshUserMonthIncentive` (queued)
- Recomputes one user-month; accepts `reopen` flag (default false).
- Recomputes over sales where the user is creator **or** assignee.
- Dispatched (with `reopen=true`) from mutation paths when a sale's paid status/amount changes.

### Auto-reopen on reversal
All mutation call sites capture the sale's **old month bucket before** the DB change and **new bucket after**, then dispatch refresh (with `reopen=true`) for each affected `(user_id, month)` pair:
- `LeadSale::syncPaymentStatus()` (paid ↔ unpaid transitions).
- Payment create / edit / delete on a paid sale.
- Paid-sale edit (total/quantity) and paid-sale delete.

This covers both bucket **moves** (old month drops the sale, new month gains it) and amount **drops** (reversal within the same month). Reopened months recompute and re-finalize immediately (their buffer has already passed).

## 6. Integration (replace legacy wiring)

- `LeadSale::syncPaymentStatus()` (`LeadSale.php:109`): remove the `SaleIncentiveService::applyToSale` block and the incentive reversal block; on paid/unpaid transitions dispatch `RefreshUserMonthIncentive` for the affected users+months. Legacy `incentive*` columns stay (history; default 0 for new sales) — no risky column-drop migration.
- Remove:
  - `SaleIncentiveSlabController` + its routes (`/incentive/slab/*`);
  - `SaleIncentiveService`;
  - `SaleIncentiveSlab` model;
  - `LeadSaleController::update_sale_incentive`;
  - `incentive_slabs` payload in `FormatsEntityData` product response.
- Permissions:
  - Remove `product_management.incentive` from `PermissionCatalog`.
  - Add `incentive_management => ['incentive' => $full]`.

## 7. Endpoints (Finance → Incentive; under existing `finance` route prefix)

Permission: `incentive_management.incentive.*`

| Endpoint | Purpose |
|---|---|
| `POST /finance/incentive/config/fetch` | user's brackets + eligibility (per product) |
| `POST /finance/incentive/config/all/fetch` | paginated users-with-config (search by user/product) |
| `POST /finance/incentive/config/save` | upsert eligibility + replace brackets per product |
| `POST /finance/incentive/monthly/fetch` | paginated `monthly_incentives` (filters: year/month, user_id, status, eligible) + per-product items + summary totals |
| `POST /finance/incentive/monthly/recalculate` | admin force-recompute a month |
| `POST /finance/incentive/monthly/status/update` | pending → approved → paid |

New `MonthlyIncentiveController` + FormRequests + ApiResources; `{success,message,data}` envelope; `RecordsActivity` logging.

## 8. Docs to update

- New plan doc (this file).
- `docs/api.md` — new endpoints.
- `docs/database.md` — new tables.
- `docs/business-rules.md` — replace the Sale Incentive Slabs section with the monthly scheme.
- `docs/architecture.md` — new service, controller, jobs.
- Permission documentation (catalog tables).

## 9. Verification

- `php artisan migrate` + `IncentiveSettingSeeder` + `PermissionSeeder` + `syncModelPermissions`.
- Pest feature tests:
  - Bucketing (last-payment month).
  - 50/50 split (both users match own slabs on own split shares; creator-only when no assignee).
  - Eligibility gates per product (amount-only, quantity@price-only, both).
  - Bracket both-range match / no-match.
  - Per-product items + aggregate consistency.
  - Finalization buffer (no finalize before +16th; finalize after).
  - `RefreshUserMonthIncentive` reopen flow (finalized month skipped with `reopen=false`; reopened + recomputed with `reopen=true`).
  - Endpoint CRUD + status transition flows.
