From b177aca35c433ab8aa653c11514048970a89f556 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 31 Aug 2026 01:33:31 +0200 Subject: [PATCH] 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 --- src/lib/server/pos-service.test.ts | 214 +++++++++++++++++++++++++++- src/lib/server/pos-service.ts | 218 ++++++++++++++++++++++++++++- 2 files changed, 430 insertions(+), 2 deletions(-) diff --git a/src/lib/server/pos-service.test.ts b/src/lib/server/pos-service.test.ts index 3a63655..dc259c8 100644 --- a/src/lib/server/pos-service.test.ts +++ b/src/lib/server/pos-service.test.ts @@ -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; + 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([]); + }); +}); diff --git a/src/lib/server/pos-service.ts b/src/lib/server/pos-service.ts index 6e8deab..f701033 100644 --- a/src/lib/server/pos-service.ts +++ b/src/lib/server/pos-service.ts @@ -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; + /** 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(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(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 { + 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(); + 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 }; +}