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:
parent
85761a8698
commit
7d2132e000
5 changed files with 42 additions and 49 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue