import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { ACTIVE_BRANCH, Branch } from "@/lib/server/models/Branch"; import { Product } from "@/lib/server/models/Product"; import { StockTransfer, IStockTransfer } from "@/lib/server/models/StockTransfer"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; import { scopedPopulate } from "@/lib/server/tenantGuard"; import { objectId } from "@/lib/server/validation"; import { recordMovement, stockOf, StockBusyError, withStockLock } from "@/lib/server/stock"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; // GET /api/stock/transfers?branchId= — recent transfers into or out of the branch(es) in scope. export async function GET(req: NextRequest) { await connectDb(); const gate = authorize(req, MANAGERS); if (!gate.ok) return gate.response; const auth = gate.auth; const scope = branchParam(auth, req.nextUrl.searchParams.get("branchId")); if (!scope.ok) return scope.response; const filter: Record = { businessId: auth.businessId }; if (scope.branchId) filter.$or = [{ fromBranchId: scope.branchId }, { toBranchId: scope.branchId }]; const transfers = await StockTransfer.find(filter) .sort({ createdAt: -1 }) .limit(100) .populate(scopedPopulate("fromBranchId", "branchName", auth.businessId)) .populate(scopedPopulate("toBranchId", "branchName", auth.businessId)) .populate(scopedPopulate("productId", "label unit", auth.businessId)) .populate(scopedPopulate("recordedBy", "name", auth.businessId)) .lean(); return NextResponse.json({ transfers }); } const transferSchema = z.object({ clientUuid: z.string().uuid(), fromBranchId: objectId.optional(), // owners must give one; managers send from their own branch toBranchId: objectId, productId: objectId, quantity: z.number().int().min(1).max(1_000_000), note: z.string().trim().max(300).optional(), }); /** Both ledger sides of a transfer. Idempotent, so it is safe to re-run on a retry. */ async function applyTransfer(t: IStockTransfer) { const common = { businessId: t.businessId, productId: t.productId, refId: t._id.toString(), recordedBy: t.recordedBy, note: t.note, }; await recordMovement({ ...common, branchId: t.fromBranchId, type: "TRANSFER_OUT", quantity: -t.quantity }); await recordMovement({ ...common, branchId: t.toBranchId, type: "TRANSFER_IN", quantity: t.quantity }); } // POST /api/stock/transfers — move stock from one branch to another. // Owners: between any two branches. Managers: out of their own branch only. export async function POST(req: NextRequest) { await connectDb(); const gate = authorize(req, MANAGERS); if (!gate.ok) return gate.response; const auth = gate.auth; const parsed = transferSchema.safeParse(await req.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } const data = parsed.data; // A retried submit returns the transfer that already happened. const existing = await StockTransfer.findOne({ businessId: auth.businessId, clientUuid: data.clientUuid }); if (existing) { await applyTransfer(existing); return NextResponse.json({ transfer: existing }, { status: 200 }); } let fromBranchId: string | null; if (auth.role === "SUPER_ADMIN") { fromBranchId = data.fromBranchId ?? null; if (!fromBranchId) return NextResponse.json({ error: "fromBranchId is required" }, { status: 400 }); } else { fromBranchId = auth.branchId; if (data.fromBranchId && data.fromBranchId !== auth.branchId) { return NextResponse.json( { error: "Branch managers can only transfer stock out of their own branch" }, { status: 403 } ); } } if (!fromBranchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 }); if (fromBranchId === data.toBranchId) { return NextResponse.json({ error: "Choose two different branches" }, { status: 400 }); } const [branchCount, product] = await Promise.all([ Branch.countDocuments({ _id: { $in: [fromBranchId, data.toBranchId] }, businessId: auth.businessId, ...ACTIVE_BRANCH }), Product.exists({ _id: data.productId, businessId: auth.businessId }), ]); if (branchCount !== 2) 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 }); // Transfers are made online by a manager, so unlike offline sales they // 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 { 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 }); } 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; } }