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