2026-09-03 10:19:21 +00:00
|
|
|
|
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 };
|
2026-08-30 23:31:53 +00:00
|
|
|
|
};
|
2026-09-03 10:19:21 +00:00
|
|
|
|
const itemCreate = async (data: Record<string, unknown>) => {
|
|
|
|
|
|
return { id: "pos-item-1", ...data };
|
2026-08-31 14:40:02 +00:00
|
|
|
|
};
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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 });
|
2026-08-31 14:40:02 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
describe("checkoutSale — discount math", () => {
|
|
|
|
|
|
let pb: ReturnType<typeof fakePb>;
|
2026-08-30 23:31:53 +00:00
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
pb = fakePb();
|
2026-08-31 14:40:02 +00:00
|
|
|
|
});
|
2026-08-30 23:33:31 +00:00
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
function lastSale(pb: ReturnType<typeof fakePb>) {
|
|
|
|
|
|
const calls = pb._salesCalls;
|
|
|
|
|
|
return calls[calls.length - 1] as Record<string, unknown>;
|
|
|
|
|
|
}
|
2026-08-31 14:40:02 +00:00
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
it("records no discount by default (kind=none, amount=0, input=0)", async () => {
|
2026-08-30 23:33:31 +00:00
|
|
|
|
const result = await checkoutSale(pb, {
|
2026-09-03 10:19:21 +00:00
|
|
|
|
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
|
2026-08-30 23:33:31 +00:00
|
|
|
|
payment_method: "cash",
|
|
|
|
|
|
});
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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);
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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
|
2026-08-30 23:33:31 +00:00
|
|
|
|
const result = await checkoutSale(pb, {
|
2026-09-03 10:19:21 +00:00
|
|
|
|
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 115 }],
|
2026-08-30 23:33:31 +00:00
|
|
|
|
payment_method: "cash",
|
2026-09-03 10:19:21 +00:00
|
|
|
|
discount_kind: "amount",
|
|
|
|
|
|
discount_input: 15,
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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);
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
it("applies a percentage discount", async () => {
|
|
|
|
|
|
// 1 × R200 = R200 gross. 10% off → R180 net.
|
2026-08-30 23:33:31 +00:00
|
|
|
|
const result = await checkoutSale(pb, {
|
2026-09-03 10:19:21 +00:00
|
|
|
|
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 200 }],
|
2026-08-30 23:33:31 +00:00
|
|
|
|
payment_method: "cash",
|
2026-09-03 10:19:21 +00:00
|
|
|
|
discount_kind: "percent",
|
|
|
|
|
|
discount_input: 10,
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
expect(result.ok).toBe(true);
|
|
|
|
|
|
if (!result.ok) return;
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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);
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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.
|
2026-08-30 23:33:31 +00:00
|
|
|
|
const result = await checkoutSale(pb, {
|
2026-09-03 10:19:21 +00:00
|
|
|
|
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 100 }],
|
|
|
|
|
|
payment_method: "cash",
|
|
|
|
|
|
discount_kind: "percent",
|
|
|
|
|
|
discount_input: 200,
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
expect(result.ok).toBe(true);
|
|
|
|
|
|
if (!result.ok) return;
|
2026-09-03 10:19:21 +00:00
|
|
|
|
const saleData = lastSale(pb);
|
|
|
|
|
|
expect(saleData.discount_amount).toBe(100);
|
|
|
|
|
|
expect(saleData.total).toBe(0);
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
it("caps a fat-finger amount at the gross total", async () => {
|
|
|
|
|
|
// 1 × R50, R999 off — should be capped at R50 off.
|
2026-08-30 23:33:31 +00:00
|
|
|
|
const result = await checkoutSale(pb, {
|
2026-09-03 10:19:21 +00:00
|
|
|
|
items: [{ stock_item_id: "si-1", qty: 1, unit_price: 50 }],
|
2026-08-30 23:33:31 +00:00
|
|
|
|
payment_method: "cash",
|
2026-09-03 10:19:21 +00:00
|
|
|
|
discount_kind: "amount",
|
|
|
|
|
|
discount_input: 999,
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
|
|
|
|
|
expect(result.ok).toBe(true);
|
|
|
|
|
|
if (!result.ok) return;
|
2026-09-03 10:19:21 +00:00
|
|
|
|
const saleData = lastSale(pb);
|
|
|
|
|
|
expect(saleData.discount_amount).toBe(50);
|
|
|
|
|
|
expect(saleData.total).toBe(0);
|
2026-08-30 23:33:31 +00:00
|
|
|
|
});
|
2026-08-30 23:34:27 +00:00
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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,
|
2026-08-30 23:34:27 +00:00
|
|
|
|
});
|
2026-09-03 10:19:21 +00:00
|
|
|
|
expect(result.ok).toBe(true);
|
|
|
|
|
|
if (!result.ok) return;
|
|
|
|
|
|
const saleData = lastSale(pb);
|
|
|
|
|
|
expect(saleData.discount_amount).toBe(0);
|
|
|
|
|
|
expect(saleData.total).toBe(115);
|
2026-08-30 23:34:27 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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,
|
2026-08-30 23:34:27 +00:00
|
|
|
|
});
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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);
|
2026-08-30 23:36:37 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-03 10:19:21 +00:00
|
|
|
|
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);
|
2026-08-30 23:36:37 +00:00
|
|
|
|
});
|
|
|
|
|
|
});
|