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>; _stockItem: Record; } { const stockItem = { id: "si-1", sku: "WID-1", cost_price: 50, current_qty: 100 }; const _salesCalls: Array> = []; // 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) => { _salesCalls.push(data); return { id: "sale-1", ...data }; }; const itemCreate = async (data: Record) => { 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; beforeEach(() => { pb = fakePb(); }); function lastSale(pb: ReturnType) { const calls = pb._salesCalls; return calls[calls.length - 1] as Record; } 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); }); });