import type { PosCatalogItem, PosDiscountKind, 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; icon?: string } }; }; /** Compiled view of a category row used for fast keyword/tag matching. */ type CategoryMatcher = { slug: string; name: string; icon: string; /** Unique keywords, sorted longest-first so e.g. "hydraulic hose" beats "hose". */ keywords: string[]; /** Lower-cased supply_tags tokens for exact-match. */ supplyTags: Set; /** Lower-cased brand tokens for exact-match. */ brands: Set; }; type CategoryRow = { slug: string; name: string; icon?: string; match_keywords?: string; match_supplier_tags?: string; match_brands?: string; }; function buildCategoryMatcher(row: CategoryRow): CategoryMatcher { const split = (s: string | undefined) => (s || "") .toLowerCase() .split(",") .map((x) => x.trim()) .filter(Boolean); const keywords = Array.from(new Set(split(row.match_keywords))).sort( (a, b) => b.length - a.length, ); return { slug: row.slug || "", name: row.name, icon: row.icon || "", keywords, supplyTags: new Set(split(row.match_supplier_tags)), brands: new Set(split(row.match_brands)), }; } /** Pick a category for a stock item, or null if nothing matches. */ function matchItem( matchers: CategoryMatcher[], item: { name: string; description: string; sku: string }, supplierTags: string, ): CategoryMatcher | null { const haystack = `${item.name || ""} ${item.description || ""} ${item.sku || ""}`.toLowerCase(); const tags = supplierTags.toLowerCase(); // Brand wins over keyword (most specific signal). for (const m of matchers) { for (const brand of m.brands) { if (brand && (haystack.includes(brand) || tags.includes(brand))) return m; } } // Then supplier tag exact match. for (const m of matchers) { for (const tag of m.supplyTags) { if (tag && tags.includes(tag)) return m; } } // Then keyword, longest first, against name+description+sku. for (const m of matchers) { for (const kw of m.keywords) { if (kw && haystack.includes(kw)) return m; } } return null; } /** * Catalog source for the POS: active stock_items only, flattened so * the frontend doesn't need to know about PB's relation/expand shape. * * Sorted newest-first: the till's default view is "recently added", so * staff see new stock without scrolling a 373-item list. * * Items don't carry a `category` relation — the existing `categories` * collection's match_keywords / match_supplier_tags / match_brands are * the source of truth, applied server-side here. The brand check uses * the supplier's `supply_tags` field; keyword and tag checks against * the item's name/description/sku. */ export async function listActiveStockCatalog(pb: PosPb): Promise { const catRows = await pb.collection("categories").getFullList({ filter: "is_active = true", sort: "display_order,name", fields: "name,slug,icon,match_keywords,match_supplier_tags,match_brands,display_order", }); const matchers = catRows .map(buildCategoryMatcher) // Drop the catch-all "Unknown / Other" so it never steals a match. .filter((m) => m.slug !== "cat_unknown"); const items = await pb.collection("stock_items").getFullList<{ id: string; sku: string; name: string; description: string; unit: string; current_qty: number; selling_price: number; cost_price: number; created: string; supplier?: string; expand?: { supplier?: { supply_tags?: string } }; }>({ filter: "is_active = true", sort: "-created", expand: "supplier", }); return items.map((it) => { const supplierTags = it.expand?.supplier?.supply_tags || ""; const match = matchItem(matchers, it, supplierTags); return { id: it.id, sku: it.sku, name: it.name, description: it.description, unit: it.unit, category: match?.name || "", category_icon: match?.icon || "", current_qty: Number(it.current_qty ?? 0), selling_price: Number(it.selling_price ?? 0), cost_price: Number(it.cost_price ?? 0), created: it.created ?? "", }; }); } // ─── 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; /** * Optional discount applied to the sale. Off-the-total (post-VAT) * — the cashier's view of "I took R100 off" is the same on the * till, the receipt, and the audit row. * * `discount_kind` is "amount" or "percent" depending on which * pill the operator picked. `discount_input` is the raw number * they typed (e.g. "10" for "10% off", or "50" for "R50 off"); * `discount_amount` (the resolved rand value) is what we * actually apply to the gross total. */ discount_kind?: PosDiscountKind; discount_input?: number; }; 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" | "pb_error"; message: string; }; /** 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. Fetch every line's stock item. Overselling is allowed on // purpose — the till gets used ahead of a physical stock take, so // current_qty is allowed to go negative rather than blocking the // sale; it gets corrected at the next stock take. We still need // each item's row (for unit_cost and the qty decrement below), and // a cart can outlive the item it references (persisted client-side, // then the stock item is deleted/deactivated before checkout) — an // unhandled getOne() failure here used to propagate all the way up // to a bare 500 with no useful message. Catch it and report which // line is the problem instead. const stockItems = new Map(); for (const line of input.items) { let item: StockItem; try { item = (await pb.collection("stock_items").getOne(line.stock_item_id)) as StockItem; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { ok: false, reason: "pb_error", message: `Stock item ${line.stock_item_id} could not be found (it may have been deleted) — remove it from the cart and try again: ${message}`, }; } stockItems.set(line.stock_item_id, item); } // 2. Totals — VAT-inclusive shelf pricing (matches the existing POS // cart's own calc: sub = total / 1.15, vat = total - sub). // // The discount is applied to the gross total (post-VAT — the // cashier's view of "I took R100 off the bill"). The gross // values are recorded alongside the post-discount values so the // accountant can still see what the till would have rung up // before the discount was applied. const grossTotal = input.items.reduce((sum, l) => sum + l.unit_price * l.qty, 0); const grossSubtotal = grossTotal / 1.15; const grossVat = grossTotal - grossSubtotal; // Resolve the discount. Cap at the gross total so a fat-finger // percentage can't take the post-discount total below zero. // `discount_input` is what the operator typed (10 for "10%" or // 50 for "R50"); `discount_amount` is the resolved rand value // we actually subtract. const discountKind: PosDiscountKind = input.discount_kind ?? "none"; const discountInput = Number(input.discount_input ?? 0) || 0; let discountAmount = 0; if (discountKind === "amount" && discountInput > 0) { discountAmount = Math.min(discountInput, grossTotal); } else if (discountKind === "percent" && discountInput > 0) { discountAmount = Math.min((grossTotal * discountInput) / 100, grossTotal); } // The receipt / till show the post-discount numbers; the // accountant can compare to gross_* on the same row. const total = Math.max(0, grossTotal - discountAmount); 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, gross_subtotal: grossSubtotal, gross_vat: grossVat, gross_total: grossTotal, discount_amount: discountAmount, discount_kind: discountKind, discount_input: discountInput, 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 || "", })); }