fix(pos): fix uncaught 500 on checkout, allow overselling into negative stock

- 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.
This commit is contained in:
Dave 2026-09-04 13:00:12 +02:00
parent 85761a8698
commit 7d2132e000
5 changed files with 42 additions and 49 deletions

View file

@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12l5 5 9-11"/></svg>Complete sale';

View file

@ -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',

View file

@ -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 () => {

View file

@ -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);
}

View file

@ -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<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 });
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