diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 156e0d9..977a2a3 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -884,18 +884,15 @@ async function confirmSale(){ sale.synced = true; sale.serverRef = j.sale && j.sale.id; } else { - // Try to read the body so we can tell apart stock-shortage - // (409) from generic 4xx/5xx. The route returns JSON on every - // non-2xx path, so this should always parse. + // The route returns JSON on every non-2xx path, so this should + // always parse. Overselling is allowed on purpose (no stock + // check blocks the sale) — every rejection here is a real + // validation/server failure, not a stock shortage. let body = null; try { body = await r.json(); } catch (_) { /* not json */ } - if (body && body.reason === 'insufficient_stock' && Array.isArray(body.shortages) && body.shortages.length) { - serverError = { kind: 'insufficient_stock', shortages: body.shortages }; - } else { - const msg = body && body.message ? body.message : ('server returned ' + r.status); - serverError = { kind: 'other', message: msg }; - throw new Error(msg); - } + const msg = body && body.message ? body.message : ('server returned ' + r.status); + serverError = { message: msg }; + throw new Error(msg); } }catch(e){ console.warn('Sale queued for sync', e); @@ -917,18 +914,7 @@ async function confirmSale(){ // cart (remove a line, lower the qty) and try again. The cart // stays open. if (serverError) { - if (serverError.kind === 'insufficient_stock') { - const lines = serverError.shortages.map(s => { - const item = state.cart.get(s.stock_item_id); - const name = item && item.p - ? (item.p.description || item.p.sku || s.stock_item_id) - : s.stock_item_id; - return `${name}: only ${s.available} in stock (you asked for ${s.requested})`; - }); - showToast('Not enough stock — ' + lines.join('; ')); - } else { - showToast('Sale rejected: ' + (serverError.kind === 'other' ? serverError.message : 'Unknown error')); - } + showToast('Sale rejected: ' + serverError.message); if (btn) { btn.disabled = false; btn.innerHTML = 'Complete sale'; diff --git a/public/pos/sw.js b/public/pos/sw.js index 1e53a11..97bc55f 100644 --- a/public/pos/sw.js +++ b/public/pos/sw.js @@ -1,5 +1,5 @@ // Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static. -const CACHE = "pos-v21"; +const CACHE = "pos-v22"; const APP_SHELL = [ './', './index.html', diff --git a/src/app/api/v1/pos/checkout/route.test.ts b/src/app/api/v1/pos/checkout/route.test.ts index d47e701..3c7e65a 100644 --- a/src/app/api/v1/pos/checkout/route.test.ts +++ b/src/app/api/v1/pos/checkout/route.test.ts @@ -52,12 +52,11 @@ describe("POST /api/v1/pos/checkout", () => { expect(body.sale.id).toBe("sale-1"); }); - it("returns 409 when stock is insufficient", async () => { + it("returns 400 when a stock item can't be found (e.g. deleted since being added to the cart)", async () => { vi.mocked(checkoutSale).mockResolvedValue({ ok: false, - reason: "insufficient_stock", - message: "one or more items do not have enough stock", - shortages: [{ stock_item_id: "si1", requested: 5, available: 2 }], + reason: "pb_error", + message: "Stock item si1 could not be found (it may have been deleted) — remove it from the cart and try again: not found", }); const res = await POST( checkoutRequest({ @@ -65,7 +64,7 @@ describe("POST /api/v1/pos/checkout", () => { payment_method: "cash", }), ); - expect(res.status).toBe(409); + expect(res.status).toBe(400); }); it("returns 400 for other checkout failures", async () => { diff --git a/src/app/api/v1/pos/checkout/route.ts b/src/app/api/v1/pos/checkout/route.ts index 2f64db4..542b9bd 100644 --- a/src/app/api/v1/pos/checkout/route.ts +++ b/src/app/api/v1/pos/checkout/route.ts @@ -33,8 +33,12 @@ import { * on success (stockErrors non-empty means the sale was recorded but one * or more lines didn't decrement stock — needs manual reconciliation; * duplicate true means this was a replay of an existing sale), - * 409 when stock is insufficient, 400 for other validation failures, - * 401 for a missing/wrong POS_API_KEY. + * 400 for validation failures, 401 for a missing/wrong POS_API_KEY. + * + * Overselling is allowed on purpose: the till is used ahead of a + * physical stock take, so a sale is never blocked for insufficient + * stock — `stock_items.current_qty` is allowed to go negative and + * gets corrected at the next stock take. */ export async function POST(req: NextRequest) { try { @@ -74,8 +78,7 @@ export async function POST(req: NextRequest) { }); if (!result.ok) { - const status = result.reason === "insufficient_stock" ? 409 : 400; - return NextResponse.json(result, { status }); + return NextResponse.json(result, { status: 400 }); } return NextResponse.json(result); } diff --git a/src/lib/server/pos-service.ts b/src/lib/server/pos-service.ts index 0e421a1..06f6796 100644 --- a/src/lib/server/pos-service.ts +++ b/src/lib/server/pos-service.ts @@ -208,9 +208,8 @@ export type PosCheckoutResult = } | { ok: false; - reason: "no_items" | "invalid_line" | "insufficient_stock" | "pb_error"; + reason: "no_items" | "invalid_line" | "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. */ @@ -299,24 +298,30 @@ export async function checkoutSale( } } - // 1. Check availability for every line before writing anything. - const shortages: Array<{ stock_item_id: string; requested: number; available: number }> = []; + // 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) { - 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 }); + 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}`, + }; } - } - if (shortages.length > 0) { - return { - ok: false, - reason: "insufficient_stock", - message: "one or more items do not have enough stock", - shortages, - }; + stockItems.set(line.stock_item_id, item); } // 2. Totals — VAT-inclusive shelf pricing (matches the existing POS