WFDASH-WEBAPP/src/lib/server/pos-service.test.ts
Dave aed3f5c861 feat: implement discount functionality in POS system
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
2026-09-03 12:19:21 +02:00

227 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, vi } from "vitest";
import { checkoutSale, type PosPb } from "./pos-service";
/**
* Pure-logic tests for the discount math. Mocks out the PB
* instance so we can pin down: gross total, applied discount, the
* post-discount subtotal/vat/total, the fields written to the
* pos_sales row, and the edge cases (cap, fat-finger, no
* discount).
*/
function fakePb(): PosPb & {
_salesCalls: Array<Record<string, unknown>>;
_stockItem: Record<string, unknown>;
} {
const stockItem = { id: "si-1", sku: "WID-1", cost_price: 50, current_qty: 100 };
const _salesCalls: Array<Record<string, unknown>> = [];
// Each collection gets its own create fn that pushes to a
// shared _salesCalls array. The PB SDK calls `create(data)`,
// not `create(collectionName, data)`, so we identify the
// collection by the data shape (sale items have a `sale`
// field, sales don't, etc).
const salesCreate = async (data: Record<string, unknown>) => {
_salesCalls.push(data);
return { id: "sale-1", ...data };
};
const itemCreate = async (data: Record<string, unknown>) => {
return { id: "pos-item-1", ...data };
};
const ledgerCreate = async () => ({ id: "ledger-1" });
const pb = {
collection: (name: string) => {
if (name === "stock_items") {
return {
getFullList: async () => [stockItem],
getList: async () => ({ items: [stockItem] }),
getOne: async () => stockItem,
create: salesCreate, // unused; placeholder
update: async () => stockItem,
};
}
if (name === "pos_sales") {
return {
getFullList: async () => [],
getList: async () => ({ items: [] }),
getOne: async () => ({ id: "sale-1" }),
create: salesCreate,
update: async () => ({}),
};
}
if (name === "pos_sale_items") {
return {
getFullList: async () => [],
getList: async () => ({ items: [] }),
getOne: async () => ({ id: "pos-item-1" }),
create: itemCreate,
update: async () => ({}),
};
}
if (name === "stock_ledger") {
return {
getFullList: async () => [],
getList: async () => ({ items: [] }),
getOne: async () => ({ id: "ledger-1" }),
create: ledgerCreate,
update: async () => ({}),
};
}
return {
getFullList: async () => [],
getList: async () => ({ items: [] }),
getOne: async () => {
throw new Error("404");
},
create: salesCreate, // fallback
update: async () => ({}),
};
},
};
return Object.assign(pb as PosPb, { _salesCalls, _stockItem: stockItem });
}
describe("checkoutSale — discount math", () => {
let pb: ReturnType<typeof fakePb>;
beforeEach(() => {
pb = fakePb();
});
function lastSale(pb: ReturnType<typeof fakePb>) {
const calls = pb._salesCalls;
return calls[calls.length - 1] as Record<string, unknown>;
}
it("records no discount by default (kind=none, amount=0, input=0)", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_kind).toBe("none");
expect(saleData.discount_amount).toBe(0);
expect(saleData.discount_input).toBe(0);
expect(saleData.gross_total).toBe(115);
expect(saleData.total).toBe(115);
});
it("applies an R-amount discount to the post-VAT total and splits VAT proportionally", async () => {
// 1 × R115 = R115 gross. R15 off → R100 net.
// sub = 100 / 1.15 ≈ 86.96
// vat = 100 - 86.96 ≈ 13.04
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 15,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_total).toBe(115);
expect(saleData.discount_kind).toBe("amount");
expect(saleData.discount_input).toBe(15);
expect(saleData.discount_amount).toBe(15);
expect(saleData.total).toBe(100);
expect(saleData.subtotal).toBeCloseTo(86.9565, 3);
expect(saleData.vat).toBeCloseTo(13.0435, 3);
});
it("applies a percentage discount", async () => {
// 1 × R200 = R200 gross. 10% off → R180 net.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 10,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_total).toBe(200);
expect(saleData.discount_amount).toBe(20);
expect(saleData.discount_input).toBe(10);
expect(saleData.total).toBe(180);
});
it("caps a fat-finger percentage at the gross total (no negative total)", async () => {
// 1 × R100, 200% off — should be capped at R100 off, not -R100.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
payment_method: "cash",
discount_kind: "percent",
discount_input: 200,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_amount).toBe(100);
expect(saleData.total).toBe(0);
});
it("caps a fat-finger amount at the gross total", async () => {
// 1 × R50, R999 off — should be capped at R50 off.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 50 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 999,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_amount).toBe(50);
expect(saleData.total).toBe(0);
});
it("ignores a discount of 0 (no change to totals, no row-level discount state)", async () => {
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 0,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.discount_amount).toBe(0);
expect(saleData.total).toBe(115);
});
it("gross_subtotal and gross_vat are recorded alongside the post-discount values", async () => {
// The accountant needs both views on the audit row.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
payment_method: "cash",
discount_kind: "amount",
discount_input: 15,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.gross_total).toBe(115);
expect(saleData.gross_subtotal).toBeCloseTo(100, 2);
expect(saleData.gross_vat).toBeCloseTo(15, 2);
expect(saleData.total).toBe(100);
});
it("adjusts cash change against the post-discount total", async () => {
// R200 - 10% = R180. Cash given R200 → change R20.
const result = await checkoutSale(pb, {
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
payment_method: "cash",
cash_given: 200,
discount_kind: "percent",
discount_input: 10,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
const saleData = lastSale(pb);
expect(saleData.change_given).toBe(20);
expect(saleData.total).toBe(180);
});
});