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

368 lines
12 KiB
TypeScript
Raw Normal View History

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<string, { current_qty: number; cost_price?: number }>;
existingSales?: Array<Record<string, unknown>>;
existingSaleItems?: Array<Record<string, unknown>>;
}) {
const created: Record<string, unknown[]> = {
pos_sales: [],
pos_sale_items: [],
stock_ledger: [],
};
const updates: Array<{ collection: string; id: string; data: Record<string, unknown> }> = [];
let saleCounter = 0;
let itemCounter = 0;
return {
pb: {
collection: (name: string) => ({
getFullList: vi.fn(),
getList: vi.fn(async (_page: number, _perPage: number, o?: Record<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
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();
});
});