2026-08-30 23:31:53 +00:00
|
|
|
import { describe, it, expect, vi } from "vitest";
|
2026-08-30 23:34:27 +00:00
|
|
|
import { checkoutSale, listActiveStockCatalog, listSalesInRange } from "./pos-service";
|
2026-08-30 23:31:53 +00:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-30 23:33:31 +00:00
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-30 23:34:27 +00:00
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
});
|