feat: Implement free issue tracking and scheme discount handling in invoices and Shop POs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user