Files
ERP_Distribution/Distribution/docs/17_promotions_schemes.md
T

23 KiB
Raw Blame History

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

{
  "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

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