WFDASH-WEBAPP/src/lib/server/pos-service.ts
Dave b177aca35c 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>
2026-08-31 01:33:31 +02:00

266 lines
9.4 KiB
TypeScript

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: <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 } };
};
/**
* 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<PosCatalogItem[]> {
const items = await pb.collection("stock_items").getFullList<StockItemWithCategory>({
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<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 };
}