import type { PosCatalogItem, PosPaymentMethod, PosSale, PosSaleItem, StockItem, } from "@/lib/types"; /** * PB client type kept loose so both the real `PocketBase` instance * and test stubs satisfy the contract — same approach as * `stock-items-service.ts`'s `StockItemsPb`. */ export type PosPb = { collection: (name: string) => { getFullList: (opts?: Record) => Promise; getList: ( page: number, perPage: number, opts?: Record, ) => Promise<{ items: T[] }>; getOne: (id: string, opts?: Record) => Promise; create: (data: Record) => Promise; update: (id: string, data: Record) => Promise; }; }; // ─── listActiveStockCatalog ──────────────────────────────────────── type StockItemWithCategory = StockItem & { expand?: { category?: { name?: string } }; }; /** * Catalog source for the POS: active stock_items only, flattened so * the frontend doesn't need to know about PB's relation/expand shape. */ export async function listActiveStockCatalog(pb: PosPb): Promise { const items = await pb.collection("stock_items").getFullList({ filter: "is_active = true", sort: "name", expand: "category", }); return items.map((it) => ({ id: it.id, sku: it.sku, name: it.name, description: it.description, unit: it.unit, category: it.expand?.category?.name || "", current_qty: Number(it.current_qty ?? 0), selling_price: Number(it.selling_price ?? 0), 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(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 }; } // ─── listSalesInRange ─────────────────────────────────────────────── export type PosSalesRangeResult = { sales: PosSale[]; items: PosSaleItem[]; }; /** * Sales + their line items for history.html / cashup.html. `from`/`to` * are PB datetime filter strings (e.g. "2026-08-30 00:00:00"). */ export async function listSalesInRange( pb: PosPb, range: { from: string; to: string }, ): Promise { const salesPage = await pb.collection("pos_sales").getList(1, 500, { filter: `created >= '${escapePbValue(range.from)}' && created <= '${escapePbValue(range.to)}'`, sort: "-created", }); const sales = salesPage.items; if (sales.length === 0) return { sales: [], items: [] }; const filter = sales.map((s) => `sale = '${escapePbValue(s.id)}'`).join(" || "); const itemsPage = await pb.collection("pos_sale_items").getList(1, 2000, { filter }); return { sales, items: itemsPage.items }; } // ─── searchCustomersForPos ────────────────────────────────────────── export type PosCustomerResult = { id: string; name: string; company: string }; /** * Customer picker for account sales. Lives behind the POS API rather * than being a direct PB query from the till — the whole point of this * app's rewrite is that no PocketBase credentials reach the client. */ export async function searchCustomersForPos( pb: PosPb, query: string, ): Promise { if (!query.trim()) return []; const safe = escapePbValue(query); const rows = await pb.collection("customers").getFullList<{ id: string; customer: string; number?: string; email?: string; expand?: { company?: { company?: string } }; }>({ filter: `customer ~ '${safe}' || number ~ '${safe}' || email ~ '${safe}'`, expand: "company", sort: "customer", }); return rows.slice(0, 8).map((c) => ({ id: c.id, name: c.customer || c.email || c.number || "—", company: c.expand?.company?.company || "", })); }