# 17 — Promotions & Trade Scheme Management **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. `applyPricingAndDiscounts` (§1) and `checkStoreSchemeEligibility` (§4) both read from it. --- ## Background / Context 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 ``` `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. ### 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. ### SchemeRedemption ``` SchemeRedemption ├── Id (Guid) — PK ├── FK_SchemeId (Guid) — required ├── FK_ShopPOId (Guid?) — nullable (see FieldInvoiceId below); the Shop PO the scheme was applied to ├── 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`/`ShopPO` restrict-delete (a scheme or Shop PO 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 | 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 | | — | `POST /api/schemes/suggest` | Body `{ storeId, items:[{itemId, quantity, unitPrice?}] }`; scheme suggestions for a Shop PO that is not saved yet | | `evaluateSchemeEligibility(storeId, shopPOId)` | `GET /api/schemes/eligible?storeId=...&orderId=...` (`orderId` is the Shop PO id) | 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 | | — | `POST /api/invoices/{id}/preview-totals` | Draft-editor preview of discount, total and free entitlement for unsaved lines | | `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": "", "triggerQty": 10, "freeItemId": "", "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": "", "freeQty": 1, "bundleComponents": [ { "itemId": "", "qty": 1 }, { "itemId": "", "qty": 1 } ] } ``` `triggerItemId`/`triggerQty` are ignored for FreeBundle — the required items come from `bundleComponents` instead. ### PUT `/api/schemes/{id}/free-goods-rule` ```json { "triggerItemId": "", "triggerQty": 10, "freeItemId": "", "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 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/Shop PO only if: the scheme's `RegionId` is null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the whole calendar day (compared by date, so a scheme ending `2026-08-31` still applies on the 31st). 3. `BuyXGetY` eligibility: the Shop PO must contain at least `TriggerQty` of `TriggerItem`; the payout scales with how many multiples of `TriggerQty` were bought (`FreeQty × floor(boughtQty / TriggerQty)`). Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier redemption never count toward a trigger, so "buy 10 get 1 of the same item" can't self-inflate. 4. `FreeBundle` eligibility: the Shop PO must contain every `SchemeBundleComponent` in at least its listed `Qty` (paid lines only, same as rule 3); payout is the flat `FreeQty` of `FreeItem` (no multiplier). `FreeQty` must be a whole number (validated on create / `PUT .../free-goods-rule`), because Shop PO lines hold whole units. 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. --- ## Offers on the PO and Invoice (implemented) Nothing is added silently: the system **suggests**, the user **accepts**, and what was accepted stays **editable**. ### 1. On the Shop PO (new and edit screens) 1. While paid lines are entered, the screen calls `POST /api/schemes/suggest` (`{ storeId, items:[{itemId, quantity, unitPrice?}] }`). The server returns the in-scope schemes (region, segment, date) the lines qualify for, plus Buy & get free / Combo gift schemes the cart is part-way to (`reason` doubles as a nudge, e.g. "Requires 10 of the trigger item; ShopPO has 6"). 2. The **Offers & Free Issues** panel shows: - **Free goods** (BuyXGetY / FreeBundle): "Qualifies for N free X" with **Add free**. The accepted free line is editable (qty, remove) and is saved as a line with `isFree = true` and the `schemeId`. - **Percentage discount** (FlatPercent / SlabDiscount): "Saves Rs. X" with **Apply discount**. Only one discount per order. The accepted scheme ids are sent as `discountSchemeIds` on create / update-items. 3. If a later paid-qty change shrinks what the order earns, the screen trims the free qty / drops the discount and says so. The server enforces the same rules on save: free qty within the entitlement, free item in stock, scheme active and in scope, at most one discount, order still qualifies. 4. Saving keeps `SchemeRedemption` in step with the lines: created or resized per free line, removed with its line, and discount redemptions created, recomputed, or dropped if the order stops qualifying. 5. `GET /api/shop-pos/{id}` returns `discounts[]` (scheme, amount) so the edit screen starts with the right state and the PO view shows a "Scheme discount" row above the total. `PO.TotalAmount` stays the full lines total. ### 2. On the invoice - The invoice copies the PO lines, including free lines (`IsFree`, `FK_SchemeId`, `SchemeName`). Free lines show in a **Free Issue** column (a "-" in the cell that does not apply), are listed **after all paid lines**, are zero-priced (price cannot be edited) and can be lowered like any line while the invoice is Draft. - `Invoice.TotalAmount = billable lines - DiscountAmount`. When the invoice is created from a PO it starts at the discounted total. - **Draft invoice editor** (`/sales/invoices/{id}/edit`): - A **Scheme discount** box lists the flat / slab discounts the current lines qualify for with **Apply discount** / **Remove**. This also covers a PO that was confirmed before a discount was applied. `PUT /api/invoices/{id}/items` accepts `discountSchemeIds` (null = leave as is). - `POST /api/invoices/{id}/preview-totals` (`{ items:[{itemId, quantity, unitPrice, isFree, isRemoved, schemeId}], discountSchemeIds? }`) returns the discount, total and per-scheme free entitlement for the unsaved lines; the editor calls it (debounced) so the Scheme discount row and Total update while typing. It changes nothing. - **Recalculation.** On every line change (add, remove, restore, re-price, quantity) and again at print and finalize, `InvoiceService.ApplyTotalsAsync` first reconciles free lines, then re-evaluates the discount against the current lines: - FlatPercent stays the set % of the current lines total; SlabDiscount can move up or down a level, or drop to 0 and return if the lines requalify. Free lines and struck-out lines are ignored. The redemption's `DiscountAmount` is updated with it, so scheme spend / ROI stay correct. - **Free lines only shrink automatically.** If the paid quantity that earned a free issue is lowered or removed, the free quantity is reduced to what is still earned (a line that earns nothing is struck out, keeping its quantity so it can be restored) and the PO's free-goods redemption is resized. Free quantity is never raised automatically; a person raises it, up to what the scheme earns. - The invoice page's Items table ends with **Subtotal / Scheme discount / Total**, and the printed A4 and receipt copies show Subtotal, Scheme discount, Total (Paid / Balance Due use the discounted total). - The older `POST /api/orders/{id}/apply-scheme` endpoint still works for an already-saved PO and flags the line the same way (and mirrors a free line onto a still-Draft invoice). ### 3. Scheme setup screens `/pricing/schemes/new`, `/pricing/schemes/{id}` (read-only) and `/pricing/schemes/{id}/edit` are full pages (no modal) built on one form. Rule types are shown in plain words: **Buy & get free** (BuyXGetY), **Combo gift** (FreeBundle), **Flat discount** (FlatPercent), **Spend more, save more** (SlabDiscount); the fields adapt to the type (product dropdowns, quantities, % and levels) with a live summary sentence. Required fields come first; Active, then the optional Region / Store segment last. The scheme name is optional (generated from the offer when blank). The rule type cannot change after creation. ### Data `ShopPOItem` and `InvoiceItem` gained `IsFree`, `FK_SchemeId` (plain Guid, no FK constraint) and a `SchemeName` snapshot. Migration `AddFreeIssueLines` also back-fills existing zero-priced redemption lines. ### Not done yet `SchemeRedemption.FK_InvoiceId` (`FieldInvoiceId` is still an unused placeholder); invoice-version snapshots do not record `IsFree`; no hint or one-click "add more free units" when the paid quantity rises; scope (date / region) is checked when a discount is first applied, not re-checked on later invoice edits; Slab "close to the next level" hints are not shown. ## Known Gaps vs. Enterprise Practice Scheme code, lifecycle (Draft/Approved/Active/Expired), priority and stackable/exclusive flags; budget and per-store caps; triggers on product groups/brands/categories or order value; tiered free-goods slabs; funding source (company vs supplier — ties into SupplierClaims and GRN `FreeQuantity`); stock reservation for the free item (the current check only sums balances). --- ## Open Questions - 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 ### 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": "", "triggerQty": 10, "freeItemId": "", "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": "", "freeQty": 2 }] ``` 20 ÷ 10 = 2 multiples → 2 free units. **Apply it:** ``` POST /api/orders/{id}/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": "", "freeQty": 1, "bundleComponents": [ { "itemId": "", "qty": 1 }, { "itemId": "", "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`.