Stop simultaneous transfers from overdrawing a branch

A transfer read the sending branch's stock and then wrote its movements
as two separate steps. Requests arriving together all saw the same
balance and all passed the check: five simultaneous transfers of 4 from
a stock of 10 all succeeded, sending 20.

The check and the write now run under withStockLock: a short lease on
one product's stock at one branch, taken with a single atomic upsert
(it only succeeds when no unexpired lease exists) and released when the
request finishes. A second request waits briefly, then sees the updated
balance; after 5 seconds it gets 409 "try again". A lease expires after
10 seconds, so a crashed request cannot block the stock for good.

Physical counts take the same lock, so a count is never computed from a
balance a transfer is changing. A double-tapped transfer that races
itself still moves stock once. Sales do not take the lock: they are
recorded even when stock runs out, by design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:57:17 +03:00
parent 1fe2421cc4
commit ee5bb5beb8
6 changed files with 277 additions and 54 deletions
+31 -22
View File
@@ -6,7 +6,7 @@ import { Product } from "@/lib/server/models/Product";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { authorize, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { clientRefId, recordMovement, stockOf } from "@/lib/server/stock";
import { clientRefId, recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
export const runtime = "nodejs";
@@ -57,27 +57,36 @@ export async function POST(req: NextRequest) {
if (!branch) return NextResponse.json({ error: "Branch not found in your business" }, { status: 404 });
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// A retried submit with the same clientUuid is a no-op.
// A retried submit with the same clientUuid is a no-op. A count reads the
// balance to work out its correction, so it runs under the stock lock:
// a transfer cannot slip in between the read and the write.
const refId = clientRefId(auth.businessId, data.clientUuid);
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
if (!already) {
const before = await stockOf(auth.businessId, branchId, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
if (delta !== 0) {
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
await recordMovement({
businessId: auth.businessId,
branchId,
productId: data.productId,
type: "ADJUSTMENT",
quantity: delta,
refId,
note: data.note ? `${label}. ${data.note}` : label,
recordedBy: auth.sub,
});
}
const lockedBranch = branchId;
try {
const quantity = await withStockLock(auth.businessId, lockedBranch, data.productId, async () => {
const already = await StockMovement.exists({ type: "ADJUSTMENT", refId });
if (!already) {
const before = await stockOf(auth.businessId, lockedBranch, data.productId);
const delta = data.mode === "COUNT" ? data.quantity - before : data.quantity;
if (delta !== 0) {
const label = data.mode === "COUNT" ? `Count: ${before} -> ${data.quantity}` : "Adjustment";
await recordMovement({
businessId: auth.businessId,
branchId: lockedBranch,
productId: data.productId,
type: "ADJUSTMENT",
quantity: delta,
refId,
note: data.note ? `${label}. ${data.note}` : label,
recordedBy: auth.sub,
});
}
}
return stockOf(auth.businessId, lockedBranch, data.productId);
});
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
} catch (err) {
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
throw err;
}
const quantity = await stockOf(auth.businessId, branchId, data.productId);
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
}
+44 -28
View File
@@ -6,7 +6,7 @@ import { Product } from "@/lib/server/models/Product";
import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer";
import { authorize, branchParam, MANAGERS } from "@/lib/server/auth";
import { objectId } from "@/lib/server/validation";
import { recordMovement, stockOf } from "@/lib/server/stock";
import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -104,33 +104,49 @@ export async function POST(req: NextRequest) {
if (!product) return NextResponse.json({ error: "Product not found in your business" }, { status: 404 });
// Transfers are made online by a manager, so unlike offline sales they
// can be refused when the sending branch does not have the stock.
const available = await stockOf(auth.businessId, fromBranchId, data.productId);
if (available < data.quantity) {
return NextResponse.json(
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
{ status: 409 }
);
}
let transfer: IStockTransfer;
// can be refused when the sending branch does not have the stock. The
// balance check and the movements run under the sending branch's stock
// lock, so two simultaneous transfers cannot both spend the same units.
const sendingBranch = fromBranchId;
try {
transfer = await StockTransfer.create({
businessId: auth.businessId,
fromBranchId,
toBranchId: data.toBranchId,
productId: data.productId,
quantity: data.quantity,
clientUuid: data.clientUuid,
note: data.note ?? null,
recordedBy: auth.sub,
});
} catch (err: unknown) {
// Two identical submits raced and the other one created it.
if ((err as { code?: number })?.code !== 11000) throw err;
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
}
await applyTransfer(transfer);
return await withStockLock(auth.businessId, sendingBranch, data.productId, async () => {
// An identical submit may have completed while we waited for the lock.
const done = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid });
if (done) {
await applyTransfer(done);
return NextResponse.json({ transfer: done }, { status: 200 });
}
return NextResponse.json({ transfer }, { status: 201 });
const available = await stockOf(auth.businessId, sendingBranch, data.productId);
if (available < data.quantity) {
return NextResponse.json(
{ error: `Only ${Math.max(available, 0)} in stock at the sending branch`, available },
{ status: 409 }
);
}
let transfer: IStockTransfer;
try {
transfer = await StockTransfer.create({
businessId: auth.businessId,
fromBranchId: sendingBranch,
toBranchId: data.toBranchId,
productId: data.productId,
quantity: data.quantity,
clientUuid: data.clientUuid,
note: data.note ?? null,
recordedBy: auth.sub,
});
} catch (err: unknown) {
// Defensive: the unique clientUuid index still guards double submits.
if ((err as { code?: number })?.code !== 11000) throw err;
transfer = (await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }))!;
}
await applyTransfer(transfer);
return NextResponse.json({ transfer }, { status: 201 });
});
} catch (err) {
if (err instanceof StockBusyError) return NextResponse.json({ error: err.message }, { status: 409 });
throw err;
}
}