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
|
SchemeRedemption
|
||||||
├── Id (Guid) — PK
|
├── Id (Guid) — PK
|
||||||
├── FK_SchemeId (Guid) — required
|
├── 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
|
├── FieldInvoiceId (Guid?) — placeholder for the future field-invoice module; no FieldInvoice
|
||||||
│ entity exists yet, so this is an unconstrained Guid for now
|
│ entity exists yet, so this is an unconstrained Guid for now
|
||||||
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
├── DiscountAmount (decimal?) — set for SlabDiscount/FlatPercent redemptions
|
||||||
@@ -87,8 +87,8 @@ SchemeRedemption
|
|||||||
│ later changes
|
│ later changes
|
||||||
├── RedeemedAt (DateTime)
|
├── RedeemedAt (DateTime)
|
||||||
```
|
```
|
||||||
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`Order` restrict-delete
|
At least one of `DiscountAmount` or `FreeQty` is set per redemption; `Scheme`/`ShopPO` restrict-delete
|
||||||
(a scheme or order with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
(a scheme or Shop PO with redemptions can't be hard-deleted, and soft-delete leaves history intact).
|
||||||
|
|
||||||
### Item (extended)
|
### 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 |
|
| — | `DELETE /api/schemes/{id}/free-goods-rule` | Clears it |
|
||||||
| — | `PUT /api/schemes/{id}/bundle-components/{itemId}` | Upserts one FreeBundle required-item row |
|
| — | `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 |
|
| — | `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 |
|
| `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 |
|
| `trackSchemeSpend(schemeId)` | `GET /api/schemes/{id}/spend` | Discount spend + free-goods cost spend |
|
||||||
| `getSchemeROI(schemeId)` | `GET /api/schemes/{id}/roi` | Uses `BaselineExpectedSales` |
|
| `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`.
|
[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
|
(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.)
|
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
|
null or matches the store's region; `EligibleSegment` is null or matches `Store.Segment`; and the
|
||||||
order's date falls within `[DateRangeFrom, DateRangeTo]`.
|
Shop PO's date falls within `[DateRangeFrom, DateRangeTo]`. `DateRangeTo` is inclusive of the
|
||||||
3. `BuyXGetY` eligibility: the order must contain at least `TriggerQty` of `TriggerItem`; the payout
|
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)`).
|
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
|
Only **paid** lines (`UnitPrice > 0`) count toward `boughtQty` — free lines added by an earlier
|
||||||
listed `Qty`; payout is the flat `FreeQty` of `FreeItem` (no multiplier).
|
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`,
|
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
|
`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.
|
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
|
## Open Questions
|
||||||
|
|
||||||
- What counts as "incremental sales" for `getSchemeROI` — **resolved for v1**: a manual
|
- 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
|
├── FK_InvoiceId, FK_ShopPOItemId, FK_ItemId
|
||||||
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
|
├── Quantity (int) — what is billed now (0..ShopPOItem.Quantity)
|
||||||
├── UnitPrice (decimal)
|
├── UnitPrice (decimal)
|
||||||
|
├── IsFree, FK_SchemeId, SchemeName — free issue copied from the PO line (UnitPrice 0); see 17_promotions_schemes.md
|
||||||
└── IsActive, CreatedAt — from Template
|
└── IsActive, CreatedAt — from Template
|
||||||
|
|
||||||
InvoiceVersion — immutable snapshot of the invoice
|
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!").
|
`*** 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`.
|
- 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.
|
- 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
|
**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
|
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.
|
also unresolved. Both cases need explicit cascade rules before implementation.
|
||||||
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
6. **Exact adjustment payload for `POST /finalize`** — *partly resolved:* quantity adjustments
|
||||||
(`{ shopPOItemId, adjustedQuantity }`) are implemented and persisted on the invoice lines.
|
(`{ 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
|
Returns and damage are still open, as is the payload shape for them. Scheme discounts and free issues are now handled
|
||||||
given to the shop at no charge) is a known case that needs its own field(s) in the payload.
|
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.
|
// Removed lines stay on the document (struck through) but are excluded from every total.
|
||||||
function totals(invoice: InvoiceResponse, payments: PaymentResponse[]) {
|
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)
|
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) {
|
export function InvoiceTemplate({ paper = 'A4', ...props }: InvoiceTemplateProps) {
|
||||||
@@ -56,9 +58,12 @@ function A4Sheet({
|
|||||||
showAgentEdits,
|
showAgentEdits,
|
||||||
className,
|
className,
|
||||||
}: Omit<InvoiceTemplateProps, 'paper'>) {
|
}: Omit<InvoiceTemplateProps, 'paper'>) {
|
||||||
const { total, paid, balance } = totals(invoice, payments)
|
const { subtotal, discount, total, paid, balance } = totals(invoice, payments)
|
||||||
const status = statusLabel(invoice)
|
const status = statusLabel(invoice)
|
||||||
const hasRemoved = invoice.items.some((i) => i.isRemoved)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -107,6 +112,7 @@ function A4Sheet({
|
|||||||
<tr className="border-b border-neutral-300 text-xs uppercase tracking-wide text-neutral-500">
|
<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="px-2 py-2 text-left font-medium">Item</th>
|
||||||
<th className="w-24 px-2 py-2 text-right font-medium">Qty</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-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>
|
<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" />}
|
{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>
|
<td colSpan={4} className="px-2 py-6 text-center text-neutral-500">No items.</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
invoice.items.map((item) => (
|
rows.map((item) => (
|
||||||
<tr
|
<tr
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={cn('break-inside-avoid border-b border-neutral-200', item.isRemoved && 'text-neutral-400 line-through')}
|
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">
|
||||||
<td className="px-2 py-2.5 text-right">
|
{item.itemName}
|
||||||
{renderQuantity && !item.isRemoved ? renderQuantity(item) : fmtQty(item.quantity)}
|
{item.isFree && item.schemeName && (
|
||||||
|
<span className="block text-xs text-neutral-500 no-underline">Free issue · {item.schemeName}</span>
|
||||||
|
)}
|
||||||
</td>
|
</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">
|
<td className="px-2 py-2.5 text-right">
|
||||||
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
|
{renderUnitPrice && !item.isRemoved ? renderUnitPrice(item) : fmtMoney(item.unitPrice)}
|
||||||
</td>
|
</td>
|
||||||
@@ -160,8 +176,14 @@ function A4Sheet({
|
|||||||
<dl className="w-72 break-inside-avoid text-sm">
|
<dl className="w-72 break-inside-avoid text-sm">
|
||||||
<div className="flex justify-between border-b py-1.5">
|
<div className="flex justify-between border-b py-1.5">
|
||||||
<dt className="text-neutral-500">Subtotal</dt>
|
<dt className="text-neutral-500">Subtotal</dt>
|
||||||
<dd>{fmtMoney(total)}</dd>
|
<dd>{fmtMoney(subtotal)}</dd>
|
||||||
</div>
|
</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 }}>
|
<div className="flex justify-between border-y py-2 text-base font-bold" style={{ borderColor: RULE }}>
|
||||||
<dt>Total</dt>
|
<dt>Total</dt>
|
||||||
<dd>{fmtMoney(total)}</dd>
|
<dd>{fmtMoney(total)}</dd>
|
||||||
@@ -225,7 +247,7 @@ function ReceiptSheet({
|
|||||||
showAgentEdits,
|
showAgentEdits,
|
||||||
className,
|
className,
|
||||||
}: Omit<InvoiceTemplateProps, 'paper'> & { paper: Exclude<InvoicePaper, 'A4'> }) {
|
}: 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 status = statusLabel(invoice)
|
||||||
const narrow = paper === '58mm'
|
const narrow = paper === '58mm'
|
||||||
const rule = <div className="my-2 border-t border-dashed border-black" />
|
const rule = <div className="my-2 border-t border-dashed border-black" />
|
||||||
@@ -252,12 +274,21 @@ function ReceiptSheet({
|
|||||||
</div>
|
</div>
|
||||||
{rule}
|
{rule}
|
||||||
<div className="space-y-1.5">
|
<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')}>
|
<div key={i.id} className={cn(i.isRemoved && 'line-through opacity-60')}>
|
||||||
<p>{i.itemName}</p>
|
<p>{i.itemName}</p>
|
||||||
<p className="flex justify-between">
|
<p className="flex justify-between">
|
||||||
|
{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>{fmtQty(i.quantity)} x {num(i.unitPrice)}</span>
|
||||||
<span>{num(i.quantity * i.unitPrice)}</span>
|
<span>{num(i.quantity * i.unitPrice)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -265,8 +296,14 @@ function ReceiptSheet({
|
|||||||
{rule}
|
{rule}
|
||||||
<p className="flex justify-between">
|
<p className="flex justify-between">
|
||||||
<span>Subtotal</span>
|
<span>Subtotal</span>
|
||||||
<span>{fmtMoney(total)}</span>
|
<span>{fmtMoney(subtotal)}</span>
|
||||||
</p>
|
</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">
|
<div className="my-1 border-y-2 border-black py-1 text-sm font-bold">
|
||||||
<p className="flex justify-between">
|
<p className="flex justify-between">
|
||||||
<span>TOTAL</span>
|
<span>TOTAL</span>
|
||||||
|
|||||||
@@ -661,6 +661,18 @@ export interface SchemeResponse {
|
|||||||
createdAt: string
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Suppliers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SupplierResponse {
|
export interface SupplierResponse {
|
||||||
@@ -973,6 +985,15 @@ export interface ShopPOItemResponse {
|
|||||||
quantityFulfilled: number
|
quantityFulfilled: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
|
isFree: boolean
|
||||||
|
schemeId: string | null
|
||||||
|
schemeName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShopPODiscount {
|
||||||
|
schemeId: string
|
||||||
|
schemeName: string
|
||||||
|
amount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopPOResponse {
|
export interface ShopPOResponse {
|
||||||
@@ -1000,6 +1021,7 @@ export interface ShopPOResponse {
|
|||||||
cancelReason: string | null
|
cancelReason: string | null
|
||||||
cancelledByName: string | null
|
cancelledByName: string | null
|
||||||
cancelledByRole: string | null
|
cancelledByRole: string | null
|
||||||
|
discounts: ShopPODiscount[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceItemResponse {
|
export interface InvoiceItemResponse {
|
||||||
@@ -1011,6 +1033,9 @@ export interface InvoiceItemResponse {
|
|||||||
quantity: number
|
quantity: number
|
||||||
unitPrice: number
|
unitPrice: number
|
||||||
isRemoved: boolean
|
isRemoved: boolean
|
||||||
|
isFree: boolean
|
||||||
|
schemeId: string | null
|
||||||
|
schemeName: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceVersionItemResponse {
|
export interface InvoiceVersionItemResponse {
|
||||||
@@ -1050,6 +1075,7 @@ export interface InvoiceResponse {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
createdAt: string
|
createdAt: string
|
||||||
items: InvoiceItemResponse[]
|
items: InvoiceItemResponse[]
|
||||||
|
discounts: ShopPODiscount[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutstandingPaymentsResponse {
|
export interface OutstandingPaymentsResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user