feat(pos): add checkoutSale with stock validation and ledger writes
Records all four tender types. Only cash sets cash_given/change_given; card, EFT and account settle outside the till, so cashup can reconcile the physical drawer. Stock decrements for every tender including account, since the goods leave on a delivery note. client_ref makes checkout idempotent: an offline-queued sale that was already committed server-side replays to the original record instead of decrementing stock twice. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
a228b02156
commit
b177aca35c
2 changed files with 430 additions and 2 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { listActiveStockCatalog } from "./pos-service";
|
||||
import { checkoutSale, listActiveStockCatalog } from "./pos-service";
|
||||
|
||||
function stubPb(items: unknown[]) {
|
||||
return {
|
||||
|
|
@ -67,3 +67,215 @@ describe("listActiveStockCatalog", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import type { PosCatalogItem, PosSale, PosSaleItem, StockItem } from "@/lib/types";
|
||||
import type {
|
||||
PosCatalogItem,
|
||||
PosPaymentMethod,
|
||||
PosSale,
|
||||
PosSaleItem,
|
||||
StockItem,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* PB client type kept loose so both the real `PocketBase` instance
|
||||
|
|
@ -48,3 +54,213 @@ export async function listActiveStockCatalog(pb: PosPb): Promise<PosCatalogItem[
|
|||
cost_price: Number(it.cost_price ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── checkoutSale ──────────────────────────────────────────────────
|
||||
|
||||
export type PosCheckoutLineInput = {
|
||||
stock_item_id: string;
|
||||
qty: number;
|
||||
unit_price: number;
|
||||
sku?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type PosCheckoutInput = {
|
||||
items: PosCheckoutLineInput[];
|
||||
payment_method: PosPaymentMethod;
|
||||
cash_given?: number;
|
||||
customer_id?: string;
|
||||
/** The till's local sale id, used as an idempotency key. */
|
||||
client_ref?: string;
|
||||
};
|
||||
|
||||
export type PosCheckoutResult =
|
||||
| {
|
||||
ok: true;
|
||||
sale: PosSale;
|
||||
items: PosSaleItem[];
|
||||
/** Lines whose ledger/qty write failed after the sale record was created. */
|
||||
stockErrors: Array<{ stock_item_id: string; error: string }>;
|
||||
/** True when this was a replay of an already-recorded `client_ref`. */
|
||||
duplicate: boolean;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: "no_items" | "invalid_line" | "insufficient_stock" | "pb_error";
|
||||
message: string;
|
||||
shortages?: Array<{ stock_item_id: string; requested: number; available: number }>;
|
||||
};
|
||||
|
||||
/** PB filter strings are built by hand here, so quote-escape anything user-supplied. */
|
||||
function escapePbValue(value: string): string {
|
||||
return value.replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a previously-recorded sale by its client-generated key.
|
||||
* Returns null when this `client_ref` has not been seen before.
|
||||
*/
|
||||
async function findSaleByClientRef(
|
||||
pb: PosPb,
|
||||
clientRef: string,
|
||||
): Promise<{ sale: PosSale; items: PosSaleItem[] } | null> {
|
||||
const page = await pb
|
||||
.collection("pos_sales")
|
||||
.getList<PosSale>(1, 1, { filter: `client_ref = '${escapePbValue(clientRef)}'` });
|
||||
const sale = page.items[0];
|
||||
if (!sale) return null;
|
||||
|
||||
const itemsPage = await pb
|
||||
.collection("pos_sale_items")
|
||||
.getList<PosSaleItem>(1, 500, { filter: `sale = '${escapePbValue(sale.id)}'` });
|
||||
return { sale, items: itemsPage.items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms a POS sale: validates stock availability, creates the
|
||||
* `pos_sales`/`pos_sale_items` rows, then for each line writes a
|
||||
* `stock_ledger` row and decrements `stock_items.current_qty`.
|
||||
*
|
||||
* Stock is decremented for every tender type, including `account` —
|
||||
* the goods physically leave the shop on a delivery note, only the
|
||||
* payment is deferred. Cash-drawer fields (`cash_given`/`change_given`)
|
||||
* are recorded only for `cash`; card, EFT and account settle outside
|
||||
* the till, so they store zero and cashup can reconcile the drawer.
|
||||
*
|
||||
* Uses the existing `customer_fulfillment` stock_ledger reason
|
||||
* (qty booked out against a customer, from `STOCK_REASON_VALUES`)
|
||||
* rather than a new POS-specific reason — `reference_type: "pos_sale"`
|
||||
* plus `reference_id: sale.id` is enough to distinguish these rows in
|
||||
* the ledger without needing a PB schema change first.
|
||||
*
|
||||
* Idempotency: when `client_ref` is supplied and already exists, the
|
||||
* original sale is returned untouched with `duplicate: true`. Without
|
||||
* this, a sale that committed server-side but whose response never
|
||||
* reached the till would be re-posted by the offline queue and
|
||||
* decrement stock a second time.
|
||||
*
|
||||
* If a line's ledger/qty write fails after the sale record already
|
||||
* exists, the sale is NOT rolled back — it's reported in
|
||||
* `stockErrors` for manual reconciliation, mirroring the
|
||||
* compensation-needed pattern in the existing
|
||||
* `/api/v1/stock/items/[id]/adjust` route.
|
||||
*/
|
||||
export async function checkoutSale(
|
||||
pb: PosPb,
|
||||
input: PosCheckoutInput,
|
||||
): Promise<PosCheckoutResult> {
|
||||
if (!Array.isArray(input.items) || input.items.length === 0) {
|
||||
return { ok: false, reason: "no_items", message: "items must be a non-empty array" };
|
||||
}
|
||||
for (const line of input.items) {
|
||||
if (!line.stock_item_id || !Number.isFinite(line.qty) || line.qty <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "invalid_line",
|
||||
message: `each item needs a stock_item_id and a positive qty (got ${JSON.stringify(line)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Idempotency replay — before any stock check, so a retry of an
|
||||
// already-banked sale can't fail on stock that has since sold out.
|
||||
const clientRef = (input.client_ref || "").trim();
|
||||
if (clientRef) {
|
||||
try {
|
||||
const existing = await findSaleByClientRef(pb, clientRef);
|
||||
if (existing) {
|
||||
return { ok: true, sale: existing.sale, items: existing.items, stockErrors: [], duplicate: true };
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, reason: "pb_error", message: `Failed to check client_ref: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Check availability for every line before writing anything.
|
||||
const shortages: Array<{ stock_item_id: string; requested: number; available: number }> = [];
|
||||
const stockItems = new Map<string, StockItem>();
|
||||
for (const line of input.items) {
|
||||
const item = (await pb.collection("stock_items").getOne(line.stock_item_id)) as StockItem;
|
||||
stockItems.set(line.stock_item_id, item);
|
||||
const available = Number(item.current_qty ?? 0);
|
||||
if (available < line.qty) {
|
||||
shortages.push({ stock_item_id: line.stock_item_id, requested: line.qty, available });
|
||||
}
|
||||
}
|
||||
if (shortages.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "insufficient_stock",
|
||||
message: "one or more items do not have enough stock",
|
||||
shortages,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Totals — VAT-inclusive shelf pricing (matches the existing POS
|
||||
// cart's own calc: sub = total / 1.15, vat = total - sub).
|
||||
const total = input.items.reduce((sum, l) => sum + l.unit_price * l.qty, 0);
|
||||
const subtotal = total / 1.15;
|
||||
const vat = total - subtotal;
|
||||
const isCash = input.payment_method === "cash";
|
||||
const cashGiven = isCash ? Number(input.cash_given ?? 0) : 0;
|
||||
const changeGiven = isCash ? Math.max(0, cashGiven - total) : 0;
|
||||
|
||||
// 3. Create the sale header.
|
||||
let sale: PosSale;
|
||||
try {
|
||||
sale = (await pb.collection("pos_sales").create({
|
||||
sale_number: "S-" + Date.now().toString(36).toUpperCase(),
|
||||
payment_method: input.payment_method,
|
||||
cash_given: cashGiven,
|
||||
change_given: changeGiven,
|
||||
subtotal,
|
||||
vat,
|
||||
total,
|
||||
customer: input.customer_id || "",
|
||||
client_ref: clientRef,
|
||||
})) as PosSale;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, reason: "pb_error", message: `Failed to create pos_sales row: ${message}` };
|
||||
}
|
||||
|
||||
// 4. Per line: sale item + ledger + qty decrement.
|
||||
const items: PosSaleItem[] = [];
|
||||
const stockErrors: Array<{ stock_item_id: string; error: string }> = [];
|
||||
for (const line of input.items) {
|
||||
try {
|
||||
const saleItem = (await pb.collection("pos_sale_items").create({
|
||||
sale: sale.id,
|
||||
stock_item: line.stock_item_id,
|
||||
sku: line.sku || "",
|
||||
description: line.description || "",
|
||||
qty: line.qty,
|
||||
unit_cost: stockItems.get(line.stock_item_id)?.cost_price ?? 0,
|
||||
unit_price: line.unit_price,
|
||||
})) as PosSaleItem;
|
||||
items.push(saleItem);
|
||||
|
||||
await pb.collection("stock_ledger").create({
|
||||
stock_item: line.stock_item_id,
|
||||
change_qty: -line.qty,
|
||||
reason: "customer_fulfillment",
|
||||
reference_type: "pos_sale",
|
||||
reference_id: sale.id,
|
||||
notes: "",
|
||||
created_by: "",
|
||||
});
|
||||
|
||||
const current = stockItems.get(line.stock_item_id)!;
|
||||
const nextQty = Number(current.current_qty ?? 0) - line.qty;
|
||||
await pb.collection("stock_items").update(line.stock_item_id, { current_qty: nextQty });
|
||||
} catch (err) {
|
||||
stockErrors.push({
|
||||
stock_item_id: line.stock_item_id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, sale, items, stockErrors, duplicate: false };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue