Wire store scheme eligibility and update docs

This commit is contained in:
2026-08-21 14:14:14 +05:30
parent aabace1bba
commit dc4b1934e1
4 changed files with 367 additions and 46 deletions
+334 -26
View File
@@ -1,70 +1,378 @@
# 17 — Promotions & Trade Scheme Management
**Status**: Not Started
**Depends On**: 01 — Item Catalog, 14 — Pricing & Supplier Master Data
**Status**: Done — migrations applied to the database
**Depends On**: 01 — Item Catalog (Done), 14 — Pricing & Supplier Master Data (Done), 02 — Stock Ledger (Done)
---
## Goal
Covers [mainPlan.md §11](./mainPlan.md#11-promotions--trade-scheme-management)
in full — an entirely new subsystem. Once built, `applyPricingAndDiscounts` (§1) and
in full — an entirely new subsystem. `applyPricingAndDiscounts` (§1) and
`checkStoreSchemeEligibility` (§4) both read from it.
---
## Background / Context
No scheme/promotion entity exists today. Every reference to "eligible discount" elsewhere in this
doc set (order pricing, field invoices, agent discount limits) currently has no scheme data source
— it only enforces the flat per-role discount ceiling from
No scheme/promotion entity existed before this step. Every reference to "eligible discount"
elsewhere in this doc set (order pricing, field invoices, agent discount limits) had no scheme data
source — it only enforced the flat per-role discount ceiling from
[08_sales_agent_controls.md](./08_sales_agent_controls.md).
For an enterprise distributor, free-goods/bundle promotions ("buy 10 get 1 free", "buy A + B get C
free") are operationally different from discount schemes because they move physical stock at zero
revenue, not just reduce a price. That distinction shaped the data model below: `BuyXGetY` and
`FreeBundle` rules are structured columns/child rows (with their own CRUD), not a JSON blob, so a
free-goods rule can be validated (item exists, positive qty, stock available) the same way any other
order line is. `SlabDiscount`/`FlatPercent` stay JSON in `RuleConfig` since they're pure
percentage-off rules with no inventory or fulfilment side effects.
---
## Data Model Changes
### Scheme
```
Scheme
├── Id (Guid) — PK, from Template
├── Name (string)
├── RuleType (enum) — BuyXGetY, SlabDiscount, FlatPercent, FreeBundle
├── RuleConfig (string?) — JSON, only used by SlabDiscount/FlatPercent (see below); null
│ for BuyXGetY/FreeBundle
├── FK_TriggerItemId (Guid?) — BuyXGetY only: the item the store must buy
├── TriggerQty (int?) — BuyXGetY only: qty of TriggerItem required to earn one payout
├── FK_FreeItemId (Guid?) — shared payout for BuyXGetY and FreeBundle: item given free
├── FreeQty (decimal?) — shared payout: qty of FreeItem given free
├── FK_RegionId (Guid?) — nullable, null = all regions
├── EligibleSegment (string?) — Store.Segment match (Gold/Silver/Key Account/...); null = all segments
├── DateRangeFrom (DateTime)
├── DateRangeTo (DateTime)
├── AgentLimitExempt (bool) — if true, this scheme's discount stacks on top of the per-role
│ discount ceiling (08_sales_agent_controls.md) unconditionally
├── BaselineExpectedSales (decimal?) — manual baseline used by getSchemeROI (see Open Questions)
├── IsActive, CreatedAt — from Template
```
`Name`, `RuleType` (`BuyXGetY`, `SlabDiscount`, `FlatPercent`), `RuleConfig` (JSON), `Eligibility`
(store segment / region scope), `DateRangeFrom`, `DateRangeTo`.
`RuleConfig` JSON shape (SlabDiscount/FlatPercent only):
- `FlatPercent` — `{ "discountPercent": 10 }`
- `SlabDiscount` — `{ "tiers": [{ "minOrderAmount": 5000, "discountPercent": 5 }, ...] }`, the
highest tier whose `minOrderAmount` the order total meets or exceeds wins.
### Scheme Redemption
### SchemeBundleComponent
```
SchemeBundleComponent
├── Id (Guid) — PK
├── FK_SchemeId (Guid) — required, cascade delete with parent Scheme
├── FK_ItemId (Guid) — required
├── Qty (int) — required qty of this item for the FreeBundle rule to trigger
```
Unique constraint on `(SchemeId, ItemId)`. Only meaningful when `Scheme.RuleType = FreeBundle` — the
order must contain every component in at least its listed `Qty` before the scheme's
`FK_FreeItemId`/`FreeQty` payout is granted.
`SchemeId`, `OrderId` (or `FieldInvoiceId`), `DiscountAmount`, `RedeemedAt` — feeds
`trackSchemeSpend` and `getSchemeROI` without re-scanning every order.
### SchemeRedemption
```
SchemeRedemption
├── Id (Guid) — PK
├── FK_SchemeId (Guid) — required
├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below)
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
│ entity exists yet, so this is an unconstrained Guid for now
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
├── FK_FreeItemId (Guid?) — set for BuyXGetY/FreeBundle redemptions
├── FreeQty (decimal?)
├── FreeItemCostValue (decimal?) — free item's Item.CostPrice snapshotted at redemption time, so
│ getSchemeROI/trackSchemeSpend stay accurate after CostPrice
│ later changes
├── RedeemedAt (DateTime)
```
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
### Item (extended)
```
Item
├── ...
├── CostPrice (decimal?) — new: cost/purchase price, used to value free-goods redemptions
```
---
## Migrations
Two migrations:
1. **AddPromotionsSchemes** (applied) — creates `Schemes`, `SchemeRedemptions`; adds `Item.CostPrice`.
2. **AddSchemeFreeGoodsRuleAndBundleComponents** (applied) — makes `Scheme.RuleConfig` nullable, adds
`Scheme.FK_TriggerItemId`/`TriggerQty`/`FK_FreeItemId`/`FreeQty`, creates `SchemeBundleComponents`.
(Split into two because the free-goods columns were promoted from a JSON blob to structured
columns/child table after the first migration had already been applied to the dev database.)
---
## API Endpoints
| Function reference | Endpoint intent |
|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` |
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` |
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` |
| Function reference | Endpoint | Notes |
|---|---|---|
| `createScheme(schemeRules, eligibility, dateRange)` | `POST /api/schemes` | `RuleConfig` required only for SlabDiscount/FlatPercent; `TriggerItemId`/`TriggerQty`/`FreeItemId`/`FreeQty`/`BundleComponents` can be supplied inline to fully create a BuyXGetY/FreeBundle scheme in one call |
| — | `GET /api/schemes` | Filterable by region |
| — | `GET /api/schemes/{id}` | Includes trigger/free item, bundle components |
| — | `PUT /api/schemes/{id}` | Updates name/eligibility/date range/`RuleConfig` |
| — | `DELETE /api/schemes/{id}` | Soft delete |
| — | `PUT /api/schemes/{id}/free-goods-rule` | Sets the BuyXGetY trigger + shared free-goods payout (also the payout for FreeBundle) |
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
| — | `DELETE /api/schemes/{id}/bundle-components/{itemId}` | Removes one FreeBundle required-item row |
| `evaluateSchemeEligibility(storeId, orderId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` | Returns eligibility for every in-scope scheme, not just one |
| `applySchemeDiscount(orderId, schemeId)` | `POST /api/orders/{id}/apply-scheme` | Adds a zero-price order line for free-goods payouts |
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
---
## Request / Response Shapes
### POST `/api/schemes` (BuyXGetY — created fully populated in one call)
```json
{
"name": "Soap Buy10Get1",
"ruleType": "BuyXGetY",
"ruleConfig": null,
"regionId": null,
"eligibleSegment": "Gold",
"dateRangeFrom": "2026-08-01T00:00:00",
"dateRangeTo": "2026-08-31T23:59:59",
"agentLimitExempt": false,
"baselineExpectedSales": 50000,
"triggerItemId": "<soapItemId>",
"triggerQty": 10,
"freeItemId": "<soapItemId>",
"freeQty": 1
}
```
`triggerItemId`/`triggerQty`/`freeItemId`/`freeQty` are optional on create — omit them for a plain
SlabDiscount/FlatPercent scheme, or omit and set them later via `PUT .../free-goods-rule` if you want
to create the shell first and configure the payout afterwards.
### POST `/api/schemes` (FreeBundle — created fully populated in one call)
```json
{
"name": "Soap+Shampoo -> Free Toothbrush",
"ruleType": "FreeBundle",
"ruleConfig": null,
"regionId": null,
"eligibleSegment": null,
"dateRangeFrom": "2026-08-01T00:00:00",
"dateRangeTo": "2026-08-31T23:59:59",
"agentLimitExempt": false,
"baselineExpectedSales": null,
"freeItemId": "<toothbrushId>",
"freeQty": 1,
"bundleComponents": [ { "itemId": "<soapId>", "qty": 1 }, { "itemId": "<shampooId>", "qty": 1 } ]
}
```
`triggerItemId`/`triggerQty` are ignored for FreeBundle — the required items come from
`bundleComponents` instead.
### PUT `/api/schemes/{id}/free-goods-rule`
```json
{ "triggerItemId": "<soapItemId>", "triggerQty": 10, "freeItemId": "<soapItemId>", "freeQty": 1 }
```
Same shape as the inline create fields — use this to add or change the free-goods payout on a
scheme that was created without one.
### PUT `/api/schemes/{id}/bundle-components/{itemId}` (FreeBundle)
```json
{ "qty": 1 }
```
Use this to add/edit one bundle component after creation.
### POST `/api/schemes` (SlabDiscount)
```json
{
"name": "Volume Discount",
"ruleType": "SlabDiscount",
"ruleConfig": "{\"tiers\":[{\"minOrderAmount\":5000,\"discountPercent\":5},{\"minOrderAmount\":10000,\"discountPercent\":8}]}",
"regionId": null,
"eligibleSegment": null,
"dateRangeFrom": "2026-08-01T00:00:00",
"dateRangeTo": "2026-08-31T23:59:59",
"agentLimitExempt": false,
"baselineExpectedSales": null
}
```
### POST `/api/orders/{id}/apply-scheme`
```json
{ "schemeId": "..." }
```
---
## Business Rules
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless explicitly flagged as an
agent-limit-exempt scheme.
- `getSchemeROI` requires a baseline (pre-scheme expected sales) to compare against — confirm data
source before implementing (see Open Questions).
1. A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
[08_sales_agent_controls.md](./08_sales_agent_controls.md) unless `AgentLimitExempt = true`.
(Enforcement against the actual ceiling is deferred — 08_sales_agent_controls.md's discount
ceiling isn't implemented in code yet; this is recorded as the target behavior.)
2. `evaluateSchemeEligibility` matches a scheme to a store/order only if: the scheme's `RegionId` is
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
order's date falls within `[DateRangeFrom, DateRangeTo]`.
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`).
4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
5. `applySchemeDiscount` adds a zero-price `OrderItem` line for the free-goods payout (`UnitPrice = 0`,
`Quantity = FreeQty`), rather than only recording a discount amount — the free line still needs to
be picked, delivered, and stock-tracked like any other sold line.
6. A free-goods redemption is blocked if the free item's available `StockBalance` (summed across all
locations) is less than the payout `FreeQty` — same out-of-stock guard as a normal sold line.
7. A scheme can only be redeemed once per order (`HasRedemptionAsync` check in `applySchemeDiscount`).
8. `SchemeRedemption.FreeItemCostValue` snapshots `Item.CostPrice` at redemption time, so
`trackSchemeSpend`/`getSchemeROI` stay accurate even after the item's cost price later changes.
9. Free goods are typically still a taxable "deemed supply" at zero net value under GST/VAT-style
regimes — not yet wired into a tax calculation, since no tax subsystem exists in this codebase
yet. Flagged for whoever builds tax/invoicing.
10. `trackSchemeSpend` = sum(`DiscountAmount`) + sum(`FreeItemCostValue × FreeQty`) across all
redemptions. `getSchemeROI` = `(actualSales − BaselineExpectedSales) / totalSpend`, where
`actualSales` is the sum of `Order.TotalAmount` for every distinct order that redeemed the scheme.
---
## Open Questions
- What counts as "incremental sales" for `getSchemeROI` — a manual baseline entered when the scheme
is created, or a trailing-period average computed automatically?
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
`BaselineExpectedSales` entered at scheme creation, not a trailing-period average. Revisit if
historical order data volume later makes an automatic trailing average preferable.
- Per-role discount ceiling stacking (`AgentLimitExempt`) is unenforced pending
[08_sales_agent_controls.md](./08_sales_agent_controls.md) having an actual ceiling implementation
to stack against.
- Deemed-supply tax treatment for free goods — deferred until a tax/invoicing subsystem exists.
---
## Implementation Notes
> Fill this section in during planning discussion before writing any code.
### File structure created / touched
```
Features/
Scheme/
SchemeController.cs (also hosts SchemeOrderController for POST /api/orders/{id}/apply-scheme)
SchemeDTOs.cs
SchemeService.cs (EvaluateRule — the core eligibility/payout calculator; ApplySchemeAsync
— the free-goods order-line + stock-check + redemption write path)
ISchemeRepository.cs
SchemeRepository.cs
Models/
Scheme.cs (new — includes SchemeRuleType enum)
SchemeBundleComponent.cs (new)
SchemeRedemption.cs (new)
Item.cs (extended — CostPrice)
Data/AppDBContext.cs
add Schemes, SchemeRedemptions, SchemeBundleComponents DbSets; relationships (Scheme→TriggerItem/
FreeItem restrict-delete, SchemeBundleComponent→Scheme cascade-delete, →Item restrict-delete,
SchemeRedemption→Scheme/Order/FreeItem restrict-delete); unique index on
SchemeBundleComponent(FK_SchemeId, FK_ItemId)
Migrations/
AddPromotionsSchemes (applied)
AddSchemeFreeGoodsRuleAndBundleComponents (applied)
Program.cs
register ISchemeRepository/SchemeRepository, SchemeService
```
### Eligibility + payout logic (pseudo)
```
EvaluateRule(scheme, orderItems):
switch scheme.RuleType:
BuyXGetY:
boughtQty = sum(orderItems where ItemId = scheme.TriggerItemId).Quantity
if boughtQty < scheme.TriggerQty: not eligible
multiplier = boughtQty / scheme.TriggerQty // integer division
→ eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty * multiplier
FreeBundle:
missing = scheme.BundleComponents where sum(orderItems qty for that item) < component.Qty
if missing.any(): not eligible
→ eligible, FreeItemId = scheme.FreeItemId, FreeQty = scheme.FreeQty
FlatPercent / SlabDiscount:
parse RuleConfig JSON, compute DiscountAmount off orderItems total
```
### applySchemeDiscount flow (pseudo)
```
ApplySchemeAsync(orderId, schemeId):
reject if scheme already redeemed against this order
eligibility = EvaluateRule(scheme, order's items)
reject if not eligible
if eligibility has a free-goods payout:
check StockBalance across all locations >= FreeQty, else reject
add OrderItem { ItemId = FreeItemId, Quantity = FreeQty, UnitPrice = 0 }
redemption.FreeItemCostValue = freeItem.CostPrice ?? 0
save SchemeRedemption { DiscountAmount, FreeItemId, FreeQty, FreeItemCostValue }
```
---
## Worked Example
Item `Soap-500ml` has `CostPrice = 12`. Store is in the "Gold" segment.
**Create the scheme (one call, fully populated):**
```
POST /api/schemes
{ "name": "Soap Buy10Get1", "ruleType": "BuyXGetY", "ruleConfig": null,
"regionId": null, "eligibleSegment": "Gold",
"dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31",
"agentLimitExempt": false, "baselineExpectedSales": 50000,
"triggerItemId": "<soapId>", "triggerQty": 10, "freeItemId": "<soapId>", "freeQty": 1 }
→ Scheme { Id, ... }
```
**Store places an order** for 20 units of `Soap-500ml` → `Order { Id, TotalAmount }`.
**Check eligibility:**
```
GET /api/schemes/eligible?storeId=...&orderId=...
→ [{ "schemeId": "...", "eligible": true, "freeItemId": "<soapId>", "freeQty": 2 }]
```
20 ÷ 10 = 2 multiples → 2 free units.
**Apply it:**
```
POST /api/orders/{orderId}/apply-scheme
{ "schemeId": "..." }
```
Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with
`FreeQty = 2`, `FreeItemCostValue = 12`.
**Spend/ROI:**
```
GET /api/schemes/{id}/spend
→ { "totalDiscountSpend": 0, "totalFreeGoodsCostSpend": 24, "totalSpend": 24, "redemptionCount": 1 }
GET /api/schemes/{id}/roi
→ { "baselineExpectedSales": 50000, "actualSales": 62000, "incrementalSales": 12000,
"totalSpend": 24, "roi": 500.0 }
```
**FreeBundle variant** ("buy Soap + Shampoo, get a free Toothbrush") — also one call:
```
POST /api/schemes
{ "name": "Soap+Shampoo -> Free Toothbrush", "ruleType": "FreeBundle", "ruleConfig": null,
"regionId": null, "eligibleSegment": null,
"dateRangeFrom": "2026-08-01", "dateRangeTo": "2026-08-31",
"agentLimitExempt": false, "baselineExpectedSales": null,
"freeItemId": "<toothbrushId>", "freeQty": 1,
"bundleComponents": [ { "itemId": "<soapId>", "qty": 1 }, { "itemId": "<shampooId>", "qty": 1 } ] }
```
Eligibility now requires both Soap ≥1 and Shampoo ≥1 in the order; payout is 1 free toothbrush,
flat (no multiplier). Components/payout can still be edited afterwards via
`PUT .../bundle-components/{itemId}` and `PUT .../free-goods-rule`.