From 87e3112a9a0951d4fb2eb4b217b3b6feac9aa86d Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Thu, 22 Oct 2026 16:39:00 +0530 Subject: [PATCH] feat: Implement free issue tracking and scheme discount handling in invoices and Shop POs --- Distribution/docs/17_promotions_schemes.md | 99 +++++++++++++++++-- .../refactor_docs/order_invoice_lifecycle.md | 9 +- .../components/invoice/invoice-template.tsx | 63 +++++++++--- distribution_fe/types/api.ts | 26 +++++ 4 files changed, 172 insertions(+), 25 deletions(-) diff --git a/Distribution/docs/17_promotions_schemes.md b/Distribution/docs/17_promotions_schemes.md index f3ef70b..6636f36 100644 --- a/Distribution/docs/17_promotions_schemes.md +++ b/Distribution/docs/17_promotions_schemes.md @@ -76,7 +76,7 @@ order must contain every component in at least its listed `Qty` before the schem SchemeRedemption ├── Id (Guid) — PK ├── FK_SchemeId (Guid) — required -├── FK_OrderId (Guid?) — nullable (see FieldInvoiceId below) +├── 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 @@ -87,8 +87,8 @@ SchemeRedemption │ 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). +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) ``` @@ -124,8 +124,10 @@ columns/child table after the first migration had already been applied to the de | — | `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 | +| — | `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` | @@ -216,13 +218,18 @@ Use this to add/edit one bundle component after creation. [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 +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 - order's date falls within `[DateRangeFrom, DateRangeTo]`. -3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout + 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)`). -4. `FreeBundle` eligibility: the order must contain every `SchemeBundleComponent` in at least its - listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier). + 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. @@ -240,6 +247,80 @@ Use this to add/edit one bundle component after creation. --- +## 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 diff --git a/Distribution/docs/refactor_docs/order_invoice_lifecycle.md b/Distribution/docs/refactor_docs/order_invoice_lifecycle.md index 1ebcc60..870963d 100644 --- a/Distribution/docs/refactor_docs/order_invoice_lifecycle.md +++ b/Distribution/docs/refactor_docs/order_invoice_lifecycle.md @@ -95,6 +95,7 @@ InvoiceItem — the live billing lines ├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId ├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity) ├── UnitPrice (decimal) +├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md └── IsActive, CreatedAt — from Template InvoiceVersion — immutable snapshot of the invoice @@ -145,7 +146,8 @@ invoice for the preview, the draft editor and the print tab: `*** PRINTED ***`, `qty x price` lines, heavy-ruled TOTAL, Paid/Balance, payments, "Thank you!"). - Money `Rs. 12,450.00`, quantities up to 3 decimals, dates `dd/MM/yyyy`. - Deleted lines are shown **struck through in every status** and excluded from all totals. -- Not included (no data in the app): company/"From" block, logo, bank details, discount, tax, due date. +- Scheme discount: shown as Subtotal / Scheme discount / Total (`Invoice.DiscountAmount`), re-derived from the current lines on every edit, print and finalize (see 17_promotions_schemes.md). Free-issue lines get a **Free Issue** column and come after the paid lines. +- Not included (no data in the app): company/"From" block, logo, bank details, tax, due date. **Print tab** — `/invoices/{id}/print` (own route group `(print)`, no dashboard chrome). Has a paper-size selector (A4 / 80mm / 58mm), sets `@page` with `margin: 0` (so the browser's own @@ -290,8 +292,9 @@ Draft → Printed → Finalized also unresolved. Both cases need explicit cascade rules before implementation. 6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments (`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines. - Returns, damage and discounts are still open, as is the payload shape for them. "Free issues" handling (items - given to the shop at no charge) is a known case that needs its own field(s) in the payload. + Returns and damage are still open, as is the payload shape for them. Scheme discounts and free issues are now handled + by the invoice itself (re-evaluated on every edit, print and finalize; free lines only ever shrink automatically) — see + [17_promotions_schemes.md](../17_promotions_schemes.md). --- diff --git a/distribution_fe/components/invoice/invoice-template.tsx b/distribution_fe/components/invoice/invoice-template.tsx index ad7e9a5..261ece6 100644 --- a/distribution_fe/components/invoice/invoice-template.tsx +++ b/distribution_fe/components/invoice/invoice-template.tsx @@ -34,9 +34,11 @@ const statusLabel = (invoice: InvoiceResponse) => // Removed lines stay on the document (struck through) but are excluded from every total. function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) { - const total = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) + const subtotal = invoice.items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) + const discount = Math.min(invoice.discountAmount ?? 0, subtotal) + const total = subtotal - discount const paid = payments.reduce((s, p) => s + p.amount, 0) - return { total, paid, balance: total - paid } + return { subtotal, discount, total, paid, balance: total - paid } } export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) { @@ -56,9 +58,12 @@ function A4Sheet({ showAgentEdits, className, }: Omit) { - const { total, paid, balance } = totals(invoice, payments) + const { subtotal, discount, total, paid, balance } = totals(invoice, payments) const status = statusLabel(invoice) const hasRemoved = invoice.items.some((i) => i.isRemoved) + const hasFree = invoice.items.some((i) => i.isFree) + // Free issues are listed after every paid line. + const rows = [...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)) return (
Item Qty + {hasFree && Free Issue} Unit Price Amount {renderActions && } @@ -118,15 +124,25 @@ function A4Sheet({ No items. ) : ( - invoice.items.map((item) => ( + rows.map((item) => ( - {item.itemName} - - {renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)} + + {item.itemName} + {item.isFree && item.schemeName && ( + Free issue · {item.schemeName} + )} + + {item.isFree ? '-' : renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)} + + {hasFree && ( + + {item.isFree ? (renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)) : '-'} + + )} {renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)} @@ -160,8 +176,14 @@ function A4Sheet({
Subtotal
-
{fmtMoney(total)}
+
{fmtMoney(subtotal)}
+ {discount > 0 && ( +
+
Scheme discount
+
-{fmtMoney(discount)}
+
+ )}
Total
{fmtMoney(total)}
@@ -225,7 +247,7 @@ function ReceiptSheet({ showAgentEdits, className, }: Omit & { paper: Exclude }) { - const { total, paid, balance } = totals(invoice, payments) + const { subtotal, discount, total, paid, balance } = totals(invoice, payments) const status = statusLabel(invoice) const narrow = paper === '58mm' const rule =
@@ -252,12 +274,21 @@ function ReceiptSheet({
{rule}
- {invoice.items.map((i) => ( + {[...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (

{i.itemName}

- {fmtQty(i.quantity)} x {num(i.unitPrice)} - {num(i.quantity * i.unitPrice)} + {i.isFree ? ( + <> + {fmtQty(i.quantity)} FREE{i.schemeName ? ` (${i.schemeName})` : ''} + {num(0)} + + ) : ( + <> + {fmtQty(i.quantity)} x {num(i.unitPrice)} + {num(i.quantity * i.unitPrice)} + + )}

))} @@ -265,8 +296,14 @@ function ReceiptSheet({ {rule}

Subtotal - {fmtMoney(total)} + {fmtMoney(subtotal)}

+ {discount > 0 && ( +

+ Scheme discount + -{fmtMoney(discount)} +

+ )}

TOTAL diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 5ec5790..5a8289e 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -661,6 +661,18 @@ export interface SchemeResponse { createdAt: string } +export interface SchemeSuggestion { + schemeId: string + schemeName: string + ruleType: SchemeRuleType + eligible: boolean + reason: string | null + discountAmount: number | null + freeItemId: string | null + freeQty: number | null + freeItemName: string | null +} + // ─── Suppliers ──────────────────────────────────────────────────────────────── export interface SupplierResponse { @@ -973,6 +985,15 @@ export interface ShopPOItemResponse { quantityFulfilled: number unitPrice: number isActive: boolean + isFree: boolean + schemeId: string | null + schemeName: string | null +} + +export interface ShopPODiscount { + schemeId: string + schemeName: string + amount: number } export interface ShopPOResponse { @@ -1000,6 +1021,7 @@ export interface ShopPOResponse { cancelReason: string | null cancelledByName: string | null cancelledByRole: string | null + discounts: ShopPODiscount[] } export interface InvoiceItemResponse { @@ -1011,6 +1033,9 @@ export interface InvoiceItemResponse { quantity: number unitPrice: number isRemoved: boolean + isFree: boolean + schemeId: string | null + schemeName: string | null } export interface InvoiceVersionItemResponse { @@ -1050,6 +1075,7 @@ export interface InvoiceResponse { isActive: boolean createdAt: string items: InvoiceItemResponse[] + discounts: ShopPODiscount[] } export interface OutstandingPaymentsResponse {