WFDASH-WEBAPP/src/lib/server/pos-service.test.ts

228 lines
7.7 KiB
TypeScript
Raw Normal View History

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);
});
});