8817c0f52f
Stock (PRD 5.2): a branch's stock of a product is now the sum of its StockMovement rows. Every movement names the document that caused it and (type, refId) is unique, so replaying a sale, retrying a transfer or a second M-Pesa callback never moves the same stock twice. - Cash sales and confirmed M-Pesa payments deduct stock; purchases of a catalogue product add it. - POST /api/stock/transfers moves stock between two branches (owner: any two; manager: out of their own) and refuses to send more than the sending branch holds. Double submits are applied once. - POST /api/stock/adjustments records opening stock, breakage or a physical count (the ledger is corrected to the counted number). - GET /api/stock returns every branch x product with LOW/OUT flags (per-product lowStockThreshold, default 10); GET /api/stock/movements is the history. Sync (PRD 5.3/5.5): POST /api/transactions/cash now returns a result per sale - saved, duplicate or rejected - instead of silently skipping replays. The old client never learned a replayed sale had synced, so rows piled up until the batch passed the 100-sale limit and every flush failed with 400. Also: - A sale can hold several products (one Transaction per line, grouped by saleId); the old single-product payload is still accepted. - One bad sale is rejected on its own instead of failing the batch. - A queued sale is only accepted from the cashier who recorded it, and stays with the branch where it happened. - A product deactivated while offline is still recorded at its price; an invalid phone is dropped rather than losing the sale; the phone is now optional for cash sales. - The price the device showed is kept and a difference is flagged (priceMismatch) instead of being silently overwritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
84 lines
3.5 KiB
TypeScript
84 lines
3.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import { connectDb } from "@/lib/server/db";
|
|
import { Branch } from "@/lib/server/models/Branch";
|
|
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";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const adjustmentSchema = z.object({
|
|
clientUuid: z.string().uuid(),
|
|
branchId: objectId.optional(), // owners must give one; managers default to their own branch
|
|
productId: objectId,
|
|
// COUNT: "we physically counted N", so the ledger is corrected to N.
|
|
// DELTA: "add or remove N", e.g. opening stock, breakage, or a delivery
|
|
// that was not recorded as a purchase.
|
|
mode: z.enum(["COUNT", "DELTA"]),
|
|
quantity: z.number().int().min(-1_000_000).max(1_000_000),
|
|
note: z.string().trim().max(300).optional(),
|
|
});
|
|
|
|
// POST /api/stock/adjustments — managers (own branch) and owners (any branch).
|
|
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 = adjustmentSchema.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;
|
|
if (data.mode === "COUNT" && data.quantity < 0) {
|
|
return NextResponse.json({ error: "A counted quantity cannot be negative" }, { status: 400 });
|
|
}
|
|
|
|
let branchId: string | null;
|
|
if (auth.role === "SUPER_ADMIN") {
|
|
branchId = data.branchId ?? null;
|
|
if (!branchId) return NextResponse.json({ error: "branchId is required" }, { status: 400 });
|
|
} else {
|
|
branchId = auth.branchId;
|
|
if (data.branchId && data.branchId !== auth.branchId) {
|
|
return NextResponse.json({ error: "Cannot adjust stock for another branch" }, { status: 403 });
|
|
}
|
|
}
|
|
if (!branchId) return NextResponse.json({ error: "No branch assignment for this account" }, { status: 403 });
|
|
|
|
const [branch, product] = await Promise.all([
|
|
Branch.exists({ _id: branchId, businessId: auth.businessId }),
|
|
Product.exists({ _id: data.productId, businessId: auth.businessId }),
|
|
]);
|
|
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.
|
|
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 quantity = await stockOf(auth.businessId, branchId, data.productId);
|
|
return NextResponse.json({ branchId, productId: data.productId, quantity }, { status: 201 });
|
|
}
|