import { describe, it, expect, vi } from "vitest"; import { checkoutSale, listActiveStockCatalog, listSalesInRange } from "./pos-service"; function stubPb(items: unknown[]) { return { collection: () => ({ getFullList: vi.fn().mockResolvedValue(items), getList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), }), }; } describe("listActiveStockCatalog", () => { it("flattens stock_items rows, pulling the expanded category name", async () => { const pb = stubPb([ { id: "si1", sku: "SKU-1", name: "Widget", description: "A widget", unit: "ea", current_qty: 12, selling_price: 99.5, cost_price: 50, expand: { category: { name: "Hardware" } }, }, { id: "si2", sku: "SKU-2", name: "Uncategorised thing", description: "", unit: "ea", current_qty: 0, selling_price: 10, cost_price: 5, }, ]); const result = await listActiveStockCatalog(pb); expect(result).toEqual([ { id: "si1", sku: "SKU-1", name: "Widget", description: "A widget", unit: "ea", category: "Hardware", current_qty: 12, selling_price: 99.5, cost_price: 50, }, { id: "si2", sku: "SKU-2", name: "Uncategorised thing", description: "", unit: "ea", category: "", current_qty: 0, selling_price: 10, cost_price: 5, }, ]); }); }); function stubCheckoutPb(opts: { stockItems: Record; existingSales?: Array>; existingSaleItems?: Array>; }) { const created: Record = { pos_sales: [], pos_sale_items: [], stock_ledger: [], }; const updates: Array<{ collection: string; id: string; data: Record }> = []; let saleCounter = 0; let itemCounter = 0; return { pb: { collection: (name: string) => ({ getFullList: vi.fn(), getList: vi.fn(async (_page: number, _perPage: number, o?: Record) => { if (name === "pos_sales") { const filter = String(o?.filter ?? ""); const items = (opts.existingSales ?? []).filter((s) => filter.includes(`'${s.client_ref}'`), ); return { items }; } if (name === "pos_sale_items") { return { items: opts.existingSaleItems ?? [] }; } return { items: [] }; }), getOne: vi.fn(async (id: string) => { const item = opts.stockItems[id]; if (!item) throw new Error(`not found: ${id}`); return { id, current_qty: item.current_qty, cost_price: item.cost_price ?? 0 }; }), create: vi.fn(async (data: Record) => { if (name === "pos_sales") { const id = `sale-${++saleCounter}`; const row = { id, created: "", updated: "", collectionId: "", collectionName: "pos_sales", ...data, }; created.pos_sales.push(row); return row; } if (name === "pos_sale_items") { const id = `sale-item-${++itemCounter}`; const row = { id, created: "", updated: "", collectionId: "", collectionName: "pos_sale_items", ...data, }; created.pos_sale_items.push(row); return row; } if (name === "stock_ledger") { created.stock_ledger.push(data); return { id: "ledger-1", ...data }; } throw new Error(`unexpected create on ${name}`); }), update: vi.fn(async (id: string, data: Record) => { updates.push({ collection: name, id, data }); return { id, ...data }; }), }), }, created, updates, }; } describe("checkoutSale", () => { it("rejects an empty cart", async () => { const { pb } = stubCheckoutPb({ stockItems: {} }); const result = await checkoutSale(pb, { items: [], payment_method: "cash" }); expect(result).toEqual({ ok: false, reason: "no_items", message: expect.any(String) }); }); it("rejects a line with a non-positive qty", async () => { const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } } }); const result = await checkoutSale(pb, { items: [{ stock_item_id: "si1", qty: 0, unit_price: 10 }], payment_method: "cash", }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("invalid_line"); }); it("rejects checkout when a line exceeds available stock", async () => { const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 2 } } }); const result = await checkoutSale(pb, { items: [{ stock_item_id: "si1", qty: 5, unit_price: 10 }], payment_method: "cash", cash_given: 50, }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe("insufficient_stock"); expect(result.shortages).toEqual([{ stock_item_id: "si1", requested: 5, available: 2 }]); } }); it("creates the sale, sale items, ledger rows, and decrements stock on a valid cash sale", async () => { const { pb, created, updates } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10, cost_price: 40 } }, }); const result = await checkoutSale(pb, { items: [ { stock_item_id: "si1", qty: 3, unit_price: 100, sku: "SKU-1", description: "Widget" }, ], payment_method: "cash", cash_given: 350, client_ref: "local-1", }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.sale.total).toBe(300); expect(result.sale.subtotal).toBeCloseTo(300 / 1.15, 5); expect(result.sale.vat).toBeCloseTo(300 - 300 / 1.15, 5); expect(result.sale.cash_given).toBe(350); expect(result.sale.change_given).toBe(50); expect(result.sale.client_ref).toBe("local-1"); expect(result.items).toHaveLength(1); expect(result.stockErrors).toEqual([]); expect(result.duplicate).toBe(false); expect(created.stock_ledger).toEqual([ { stock_item: "si1", change_qty: -3, reason: "customer_fulfillment", reference_type: "pos_sale", reference_id: result.sale.id, notes: "", created_by: "", }, ]); expect(updates).toEqual([{ collection: "stock_items", id: "si1", data: { current_qty: 7 } }]); }); // Only cash puts money in the drawer. Card/EFT settle outside the till // and account is pay-later, so all three record zero cash and zero // change — cashup relies on that to reconcile the physical drawer. it.each(["card", "eft", "account"] as const)( "records no cash and no change for a %s sale", async (method) => { const { pb } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } } }); const result = await checkoutSale(pb, { items: [{ stock_item_id: "si1", qty: 1, unit_price: 115 }], payment_method: method, cash_given: 500, }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.sale.payment_method).toBe(method); expect(result.sale.cash_given).toBe(0); expect(result.sale.change_given).toBe(0); expect(result.sale.total).toBe(115); }, ); it("still decrements stock for an account sale (goods leave on a delivery note)", async () => { const { pb, created, updates } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } }, }); const result = await checkoutSale(pb, { items: [{ stock_item_id: "si1", qty: 4, unit_price: 25 }], payment_method: "account", customer_id: "cust-1", }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.sale.customer).toBe("cust-1"); expect(created.stock_ledger).toHaveLength(1); expect(updates).toEqual([{ collection: "stock_items", id: "si1", data: { current_qty: 6 } }]); }); it("replays an already-recorded client_ref without writing anything again", async () => { const { pb, created, updates } = stubCheckoutPb({ stockItems: { si1: { current_qty: 10 } }, existingSales: [{ id: "sale-existing", client_ref: "local-dupe", total: 300 }], existingSaleItems: [{ id: "si-existing", sale: "sale-existing", qty: 3 }], }); const result = await checkoutSale(pb, { items: [{ stock_item_id: "si1", qty: 3, unit_price: 100 }], payment_method: "cash", cash_given: 300, client_ref: "local-dupe", }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.duplicate).toBe(true); expect(result.sale.id).toBe("sale-existing"); expect(result.items).toHaveLength(1); // The whole point: no second sale, no second ledger row, no second decrement. expect(created.pos_sales).toEqual([]); expect(created.stock_ledger).toEqual([]); expect(updates).toEqual([]); }); }); describe("listSalesInRange", () => { it("fetches sales in the range and their line items in one follow-up call", async () => { const salesGetList = vi.fn().mockResolvedValue({ items: [ { id: "sale-1", total: 100 }, { id: "sale-2", total: 200 }, ], }); const itemsGetList = vi.fn().mockResolvedValue({ items: [ { id: "si-1", sale: "sale-1", qty: 2 }, { id: "si-2", sale: "sale-2", qty: 1 }, ], }); const pb = { collection: (name: string) => { if (name === "pos_sales") { return { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: salesGetList, }; } if (name === "pos_sale_items") { return { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: itemsGetList, }; } throw new Error(`unexpected collection ${name}`); }, }; const result = await listSalesInRange(pb, { from: "2026-08-30 00:00:00", to: "2026-08-30 23:59:59", }); expect(result.sales).toHaveLength(2); expect(result.items).toHaveLength(2); expect(salesGetList).toHaveBeenCalledWith(1, 500, { filter: "created >= '2026-08-30 00:00:00' && created <= '2026-08-30 23:59:59'", sort: "-created", }); expect(itemsGetList).toHaveBeenCalledWith(1, 2000, { filter: "sale = 'sale-1' || sale = 'sale-2'", }); }); it("skips the items query when there are no sales in range", async () => { const salesGetList = vi.fn().mockResolvedValue({ items: [] }); const itemsGetList = vi.fn(); const pb = { collection: (name: string) => name === "pos_sales" ? { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: salesGetList, } : { getFullList: vi.fn(), getOne: vi.fn(), create: vi.fn(), update: vi.fn(), getList: itemsGetList, }, }; const result = await listSalesInRange(pb, { from: "2026-08-30 00:00:00", to: "2026-08-30 23:59:59", }); expect(result).toEqual({ sales: [], items: [] }); expect(itemsGetList).not.toHaveBeenCalled(); }); });