23 KiB
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
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.
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 whoseminOrderAmountthe 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:
- AddPromotionsSchemes (applied) — creates
Schemes,SchemeRedemptions; addsItem.CostPrice. - AddSchemeFreeGoodsRuleAndBundleComponents (applied) — makes
Scheme.RuleConfignullable, addsScheme.FK_TriggerItemId/TriggerQty/FK_FreeItemId/FreeQty, createsSchemeBundleComponents.
(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)
{
"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)
{
"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
{ "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)
{ "qty": 1 }
Use this to add/edit one bundle component after creation.
POST /api/schemes (SlabDiscount)
{
"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
{ "schemeId": "..." }
Business Rules
- A scheme discount stacks with, but does not bypass, the per-role discount ceiling in
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.) evaluateSchemeEligibilitymatches a scheme to a store/Shop PO only if: the scheme'sRegionIdis null or matches the store's region;EligibleSegmentis null or matchesStore.Segment; and the Shop PO's date falls within[DateRangeFrom, DateRangeTo].DateRangeTois inclusive of the whole calendar day (compared by date, so a scheme ending2026-08-31still applies on the 31st).BuyXGetYeligibility: the Shop PO must contain at leastTriggerQtyofTriggerItem; the payout scales with how many multiples ofTriggerQtywere bought (FreeQty × floor(boughtQty / TriggerQty)). Only paid lines (UnitPrice > 0) count towardboughtQty— 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.FreeBundleeligibility: the Shop PO must contain everySchemeBundleComponentin at least its listedQty(paid lines only, same as rule 3); payout is the flatFreeQtyofFreeItem(no multiplier).FreeQtymust be a whole number (validated on create /PUT .../free-goods-rule), because Shop PO lines hold whole units.applySchemeDiscountadds a zero-priceOrderItemline 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.- A free-goods redemption is blocked if the free item's available
StockBalance(summed across all locations) is less than the payoutFreeQty— same out-of-stock guard as a normal sold line. - A scheme can only be redeemed once per order (
HasRedemptionAsynccheck inapplySchemeDiscount). SchemeRedemption.FreeItemCostValuesnapshotsItem.CostPriceat redemption time, sotrackSchemeSpend/getSchemeROIstay accurate even after the item's cost price later changes.- 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.
trackSchemeSpend= sum(DiscountAmount) + sum(FreeItemCostValue × FreeQty) across all redemptions.getSchemeROI=(actualSales − BaselineExpectedSales) / totalSpend, whereactualSalesis the sum ofOrder.TotalAmountfor 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)
- 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 (reasondoubles as a nudge, e.g. "Requires 10 of the trigger item; ShopPO has 6"). - 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 = trueand theschemeId. - Percentage discount (FlatPercent / SlabDiscount): "Saves Rs. X" with Apply discount. Only one discount
per order. The accepted scheme ids are sent as
discountSchemeIdson create / update-items.
- 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
- 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.
- Saving keeps
SchemeRedemptionin 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. GET /api/shop-pos/{id}returnsdiscounts[](scheme, amount) so the edit screen starts with the right state and the PO view shows a "Scheme discount" row above the total.PO.TotalAmountstays 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}/itemsacceptsdiscountSchemeIds(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.
- 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.
- Recalculation. On every line change (add, remove, restore, re-price, quantity) and again at print and finalize,
InvoiceService.ApplyTotalsAsyncfirst 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
DiscountAmountis 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.
- 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
- 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-schemeendpoint 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 manualBaselineExpectedSalesentered 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 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": "<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/{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": "<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.