feat: Implement free issue tracking and scheme discount handling in invoices and Shop POs

This commit is contained in:
2026-10-22 16:39:00 +05:30
parent 801276559d
commit 87e3112a9a
4 changed files with 172 additions and 25 deletions
+90 -9
View File
@@ -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).
---
@@ -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<InvoiceTemplateProps, 'paper'>) {
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 (
<div
@@ -107,6 +112,7 @@ function A4Sheet({
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
<th className="px-2 py-2 text-left font-medium">Item</th>
<th className="w-24 px-2 py-2 text-right font-medium">Qty</th>
{hasFree && <th className="w-24 px-2 py-2 text-right font-medium">Free Issue</th>}
<th className="w-36 px-2 py-2 text-right font-medium">Unit Price</th>
<th className="w-40 px-2 py-2 text-right font-medium">Amount</th>
{renderActions && <th className="w-24 px-2 py-2 print:hidden" />}
@@ -118,15 +124,25 @@ function A4Sheet({
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
</tr>
) : (
invoice.items.map((item) => (
rows.map((item) => (
<tr
key={item.id}
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
>
<td className="px-2 py-2.5">{item.itemName}</td>
<td className="px-2 py-2.5 text-right">
{renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
<td className="px-2 py-2.5">
{item.itemName}
{item.isFree && item.schemeName && (
<span className="block text-xs text-neutral-500 no-underline">Free issue · {item.schemeName}</span>
)}
</td>
<td className="px-2 py-2.5 text-right">
{item.isFree ? '-' : renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
</td>
{hasFree && (
<td className="px-2 py-2.5 text-right">
{item.isFree ? (renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)) : '-'}
</td>
)}
<td className="px-2 py-2.5 text-right">
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
</td>
@@ -160,8 +176,14 @@ function A4Sheet({
<dl className="w-72 break-inside-avoid text-sm">
<div className="flex justify-between border-b py-1.5">
<dt className="text-neutral-500">Subtotal</dt>
<dd>{fmtMoney(total)}</dd>
<dd>{fmtMoney(subtotal)}</dd>
</div>
{discount > 0 && (
<div className="flex justify-between border-b py-1.5">
<dt className="text-neutral-500">Scheme discount</dt>
<dd>-{fmtMoney(discount)}</dd>
</div>
)}
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
<dt>Total</dt>
<dd>{fmtMoney(total)}</dd>
@@ -225,7 +247,7 @@ function ReceiptSheet({
showAgentEdits,
className,
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
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 = <div className="my-2 border-t border-dashed border-black" />
@@ -252,12 +274,21 @@ function ReceiptSheet({
</div>
{rule}
<div className="space-y-1.5">
{invoice.items.map((i) => (
{[...invoice.items].sort((a, b) => Number(a.isFree) - Number(b.isFree)).map((i) => (
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
<p>{i.itemName}</p>
<p className="flex justify-between">
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
<span>{num(i.quantity * i.unitPrice)}</span>
{i.isFree ? (
<>
<span>{fmtQty(i.quantity)} FREE{i.schemeName ? ` (${i.schemeName})` : ''}</span>
<span>{num(0)}</span>
</>
) : (
<>
<span>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
<span>{num(i.quantity * i.unitPrice)}</span>
</>
)}
</p>
</div>
))}
@@ -265,8 +296,14 @@ function ReceiptSheet({
{rule}
<p className="flex justify-between">
<span>Subtotal</span>
<span>{fmtMoney(total)}</span>
<span>{fmtMoney(subtotal)}</span>
</p>
{discount > 0 && (
<p className="flex justify-between">
<span>Scheme discount</span>
<span>-{fmtMoney(discount)}</span>
</p>
)}
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
<p className="flex justify-between">
<span>TOTAL</span>
+26
View File
@@ -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 {