- checkoutSale's stock-availability loop called pb.getOne() with no try/catch, so a stale/deleted stock_item_id in a persisted cart threw all the way up to a bare 500 with no useful message. Now caught and returned as a clear pb_error the till can display. - Removed the insufficient-stock block entirely: the till is used ahead of a physical stock take, so a sale must never be blocked for lack of stock — current_qty is now allowed to go negative and gets corrected at the next stock take. Drops the now-dead insufficient_stock/shortages branches from the route, service types, and client error handling.
484 lines
17 KiB
TypeScript
484 lines
17 KiB
TypeScript
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: <T = unknown>(opts?: Record<string, unknown>) => Promise<T[]>;
|
|
getList: <T = unknown>(
|
|
page: number,
|
|
perPage: number,
|
|
opts?: Record<string, unknown>,
|
|
) => Promise<{ items: T[] }>;
|
|
getOne: <T = unknown>(id: string, opts?: Record<string, unknown>) => Promise<T>;
|
|
create: <T = unknown>(data: Record<string, unknown>) => Promise<T>;
|
|
update: <T = unknown>(id: string, data: Record<string, unknown>) => Promise<T>;
|
|
};
|
|
};
|
|
|
|
// ─── 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<string>;
|
|
/** Lower-cased brand tokens for exact-match. */
|
|
brands: Set<string>;
|
|
};
|
|
|
|
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<PosCatalogItem[]> {
|
|
const catRows = await pb.collection("categories").getFullList<CategoryRow>({
|
|
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<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. 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<string, StockItem>();
|
|
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<PosSalesRangeResult> {
|
|
const salesPage = await pb.collection("pos_sales").getList<PosSale>(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<PosSaleItem>(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<PosCustomerResult[]> {
|
|
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 || "",
|
|
}));
|
|
}
|