diff --git a/src/app/api/mpesa/callback/route.ts b/src/app/api/mpesa/callback/route.ts index 653bbc6..c7a84c0 100644 --- a/src/app/api/mpesa/callback/route.ts +++ b/src/app/api/mpesa/callback/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { connectDb } from "@/lib/server/db"; import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; +import { recordSaleMovement } from "@/lib/server/stock"; import { MpesaCallbackLog } from "@/lib/server/models/MpesaCallbackLog"; export const runtime = "nodejs"; @@ -99,6 +100,7 @@ export async function POST(req: NextRequest) { txn.mpesaDetails.resultDesc = resultDesc; txn.mpesaDetails.receiptNumber = mpesaReceiptNumber; await txn.save(); + await recordSaleMovement(txn); await MpesaCallbackLog.create({ sourceIp, diff --git a/src/app/api/mpesa/status/[transactionId]/route.ts b/src/app/api/mpesa/status/[transactionId]/route.ts index 3e2d8d9..a04ec25 100644 --- a/src/app/api/mpesa/status/[transactionId]/route.ts +++ b/src/app/api/mpesa/status/[transactionId]/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; +import { isValidObjectId } from "mongoose"; import { connectDb } from "@/lib/server/db"; import { isMpesaEnabled } from "@/lib/server/env"; import { Transaction } from "@/lib/server/models/Transaction"; import { getAuth } from "@/lib/server/auth"; import { queryStkStatus } from "@/lib/server/mpesa"; +import { recordSaleMovement } from "@/lib/server/stock"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -14,6 +16,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI const auth = getAuth(req); if (!auth) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + if (!isValidObjectId(params.transactionId)) return NextResponse.json({ error: "Transaction not found" }, { status: 404 }); const txn = await Transaction.findOne({ _id: params.transactionId, businessId: auth.businessId }); if (!txn) return NextResponse.json({ error: "Transaction not found" }, { status: 404 }); @@ -29,6 +32,7 @@ export async function GET(req: NextRequest, { params }: { params: { transactionI fresh.mpesaDetails.resultCode = code; fresh.mpesaDetails.resultDesc = result.ResultDesc; await fresh.save(); + await recordSaleMovement(fresh); return NextResponse.json(fresh); } } diff --git a/src/app/api/purchases/route.ts b/src/app/api/purchases/route.ts index 00f6fc1..6bd08c5 100644 --- a/src/app/api/purchases/route.ts +++ b/src/app/api/purchases/route.ts @@ -5,6 +5,7 @@ import { Purchase } from "@/lib/server/models/Purchase"; import { Product } from "@/lib/server/models/Product"; import { Branch } from "@/lib/server/models/Branch"; import { objectId } from "@/lib/server/validation"; +import { recordMovement } from "@/lib/server/stock"; import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; export const runtime = "nodejs"; @@ -57,8 +58,9 @@ const createSchema = z.object({ // POST /api/purchases — records a supplier purchase. The buying price is // snapshotted (unitCost/totalCost) so historical records never shift when -// product prices change later. A completed purchase conceptually increases -// branch stock. +// product prices change later. A purchase of a catalogue product adds its +// quantity to the branch's stock ledger; a free-text item (no productId) +// is recorded as a cost only. export async function POST(req: NextRequest) { await connectDb(); const gate = authorize(req, MANAGERS); @@ -125,5 +127,18 @@ export async function POST(req: NextRequest) { recordedBy: auth.sub, }); + if (productId) { + await recordMovement({ + businessId: auth.businessId, + branchId: branchId!, + productId, + type: "PURCHASE", + quantity: parsed.data.quantity, + refId: purchase._id.toString(), + note: `From ${parsed.data.supplierName}`, + recordedBy: auth.sub, + }); + } + return NextResponse.json({ purchase }, { status: 201 }); } diff --git a/src/app/api/stock/adjustments/route.ts b/src/app/api/stock/adjustments/route.ts new file mode 100644 index 0000000..06f5fd8 --- /dev/null +++ b/src/app/api/stock/adjustments/route.ts @@ -0,0 +1,83 @@ +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 }); +} diff --git a/src/app/api/stock/movements/route.ts b/src/app/api/stock/movements/route.ts new file mode 100644 index 0000000..5baa387 --- /dev/null +++ b/src/app/api/stock/movements/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { connectDb } from "@/lib/server/db"; +import { StockMovement } from "@/lib/server/models/StockMovement"; +import { authorize, branchParam, MANAGERS } from "@/lib/server/auth"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/stock/movements?branchId=&limit= — recent ledger entries, newest first. +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 limit = Math.min(Number(req.nextUrl.searchParams.get("limit")) || 50, 200); + + const filter: Record = { businessId: auth.businessId }; + if (scope.branchId) filter.branchId = scope.branchId; + + const movements = await StockMovement.find(filter) + .sort({ createdAt: -1 }) + .limit(limit) + .populate("branchId", "branchName") + .populate("productId", "label unit") + .populate("recordedBy", "name") + .lean(); + return NextResponse.json({ movements }); +} diff --git a/src/app/api/stock/route.ts b/src/app/api/stock/route.ts new file mode 100644 index 0000000..190cab8 --- /dev/null +++ b/src/app/api/stock/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from "next/server"; +import { connectDb } from "@/lib/server/db"; +import { authorize, branchParam } from "@/lib/server/auth"; +import { getStockLevels } from "@/lib/server/stock"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// GET /api/stock?branchId= — current stock per branch and product. +// Owners see every branch (or one, via branchId); everyone else sees only +// their own branch. +export async function GET(req: NextRequest) { + await connectDb(); + const gate = authorize(req); + 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; + if (auth.role !== "SUPER_ADMIN" && !scope.branchId) return NextResponse.json({ levels: [] }); + + const levels = await getStockLevels(auth.businessId, scope.branchId); + return NextResponse.json({ levels }); +} diff --git a/src/app/api/stock/transfers/route.ts b/src/app/api/stock/transfers/route.ts new file mode 100644 index 0000000..b843ee6 --- /dev/null +++ b/src/app/api/stock/transfers/route.ts @@ -0,0 +1,136 @@ +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 { 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"; + +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("fromBranchId", "branchName") + .populate("toBranchId", "branchName") + .populate("productId", "label unit") + .populate("recordedBy", "name") + .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 }), + 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. + 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; + 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 NextResponse.json({ transfer }, { status: 201 }); +} diff --git a/src/app/api/transactions/cash/route.ts b/src/app/api/transactions/cash/route.ts index 67a41a4..b87db58 100644 --- a/src/app/api/transactions/cash/route.ts +++ b/src/app/api/transactions/cash/route.ts @@ -1,29 +1,67 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; -import { Transaction } from "@/lib/server/models/Transaction"; +import { Transaction, ITransaction } from "@/lib/server/models/Transaction"; import { Product, BranchPricing } from "@/lib/server/models/Product"; -import { getAuth, requireRole, resolveBranchScope } from "@/lib/server/auth"; +import { Branch } from "@/lib/server/models/Branch"; +import { authorize } from "@/lib/server/auth"; import { normalizeKenyanPhone } from "@/lib/server/phone"; +import { objectId } from "@/lib/server/validation"; +import { recordSaleMovement } from "@/lib/server/stock"; +import { MAX_LINES_PER_SALE, MAX_SALES_PER_REQUEST, type SaleResult } from "@/lib/syncProtocol"; export const runtime = "nodejs"; -const saleSchema = z.object({ - clientUuid: z.string().uuid(), - customerName: z.string().trim().max(120).optional(), - customerPhone: z.string().trim().min(9).max(15), - productId: z.string().min(1), +const lineSchema = z.object({ + productId: objectId, quantity: z.number().int().min(1).max(500), - clientSubmittedAt: z.string().datetime().optional(), + unitPrice: z.number().nonnegative().optional(), // what the device showed; informational }); -const bodySchema = z.object({ sales: z.array(saleSchema).min(1).max(100) }); +const saleSchema = z.preprocess( + // Older clients sent one product per sale at the top level. + (raw) => { + const r = raw as Record | null; + if (r && !Array.isArray(r.lines) && r.productId) { + return { ...r, lines: [{ productId: r.productId, quantity: r.quantity }] }; + } + return raw; + }, + z.object({ + clientUuid: z.string().uuid(), + staffId: objectId.optional(), // who recorded it on the device + branchId: objectId.optional(), // where it was recorded + customerName: z.string().trim().max(120).optional(), + customerPhone: z.string().trim().max(20).optional(), + clientSubmittedAt: z.string().datetime().optional(), + lines: z.array(lineSchema).min(1).max(MAX_LINES_PER_SALE), + }) +); +type SaleInput = z.infer; + +const bodySchema = z.object({ sales: z.array(z.unknown()).min(1).max(MAX_SALES_PER_REQUEST) }); + +/** + * POST /api/transactions/cash — record cash sales, one or many, live or + * replayed from the device's offline queue. + * + * Every sale gets its own result so the device knows exactly what to do + * with each queued row: + * saved newly recorded -> mark synced + * duplicate already recorded earlier -> mark synced (this is a replay) + * rejected can never be recorded -> stop retrying, show the cashier + * One bad sale never blocks the others in the batch. + * + * Idempotency: each line's clientUuid is `${saleUuid}:${index}` and is + * unique in the database; the stock movement for each line is keyed on + * the transaction id. Replaying a sale any number of times records it, + * and deducts its stock, exactly once. + */ export async function POST(req: NextRequest) { await connectDb(); - const auth = getAuth(req); - if (!requireRole(auth, "DELIVERY", "BRANCH_MANAGER")) { - return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); - } + const gate = authorize(req, ["DELIVERY", "BRANCH_MANAGER"]); + if (!gate.ok) return gate.response; + const auth = gate.auth; const json = await req.json().catch(() => null); const normalized = Array.isArray(json?.sales) ? json : { sales: [json] }; @@ -31,61 +69,124 @@ export async function POST(req: NextRequest) { if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } - - const branchId = resolveBranchScope(auth!); - if (!branchId) { + if (!auth.branchId) { return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 }); } - const { sales } = parsed.data; - const productIds = Array.from(new Set(sales.map((s) => s.productId))); - const products = await Product.find({ _id: { $in: productIds }, businessId: auth!.businessId }).lean(); - const overrides = await BranchPricing.find({ branchId, productId: { $in: productIds }, businessId: auth!.businessId }).lean(); - const productMap = new Map(products.map((p) => [p._id.toString(), p])); - const overrideMap = new Map(overrides.map((o) => [o.productId.toString(), o.unitPrice])); - - const rejected: { clientUuid: string; error: string }[] = []; - const inserted: any[] = []; - - for (const sale of sales) { - let phone: string; - try { - phone = normalizeKenyanPhone(sale.customerPhone); - } catch (e: any) { - rejected.push({ clientUuid: sale.clientUuid, error: e.message }); - continue; - } - const product = productMap.get(sale.productId); - if (!product || !product.isActive) { - rejected.push({ clientUuid: sale.clientUuid, error: "Product not available" }); - continue; - } - const unitPrice = overrideMap.get(sale.productId) ?? product.unitPrice; - - try { - const created = await Transaction.create({ - businessId: auth!.businessId, - branchId, - staffId: auth!.sub, - customerName: sale.customerName ?? null, - customerPhone: phone, - productId: product._id, - sizeLiters: product.sizeLiters, - quantity: sale.quantity, - unitPrice, - amountTotal: Math.round(sale.quantity * unitPrice * 100) / 100, - paymentMethod: "CASH", - paymentStatus: "SUCCESS", - clientUuid: sale.clientUuid, - syncedFromOffline: !!sale.clientSubmittedAt, - clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null, - }); - inserted.push(created); - } catch (err: any) { - if (err?.code === 11000) continue; - rejected.push({ clientUuid: sale.clientUuid, error: "Could not save this sale" }); + const sales: SaleInput[] = []; + const results: SaleResult[] = []; + for (const raw of parsed.data.sales) { + const sale = saleSchema.safeParse(raw); + if (sale.success) { + sales.push(sale.data); + } else { + const clientUuid = String((raw as { clientUuid?: unknown })?.clientUuid ?? ""); + results.push({ clientUuid, status: "rejected", error: sale.error.issues[0]?.message ?? "Invalid sale" }); } } - return NextResponse.json({ inserted, rejected }); + // Tenant data needed to price every line in the batch, in three queries. + const productIds = Array.from(new Set(sales.flatMap((s) => s.lines.map((l) => l.productId)))); + const branchIds = Array.from(new Set([auth.branchId, ...sales.map((s) => s.branchId ?? auth.branchId!)])); + const [products, overrides, branches] = await Promise.all([ + Product.find({ _id: { $in: productIds }, businessId: auth.businessId }).lean(), + BranchPricing.find({ branchId: { $in: branchIds }, productId: { $in: productIds }, businessId: auth.businessId }).lean(), + Branch.find({ _id: { $in: branchIds }, businessId: auth.businessId }).select("_id").lean(), + ]); + const productMap = new Map(products.map((p) => [p._id.toString(), p])); + const overrideMap = new Map(overrides.map((o) => [`${o.branchId}:${o.productId}`, o.unitPrice])); + const tenantBranches = new Set(branches.map((b) => b._id.toString())); + + for (const sale of sales) { + results.push(await recordSale(sale)); + } + + async function recordSale(sale: SaleInput): Promise { + const reject = (error: string): SaleResult => ({ clientUuid: sale.clientUuid, status: "rejected", error }); + + // A queued sale belongs to the cashier who made it. The device only + // sends its signed-in user's rows; anything else is refused rather than + // credited to the wrong person. + if (sale.staffId && sale.staffId !== auth.sub) { + return reject("This sale was recorded by a different user. Sign in as them to sync it."); + } + // The sale stays with the branch where it happened, even if the + // cashier has since been moved — as long as that branch is in this business. + const branchId = sale.branchId ?? auth.branchId!; + if (!tenantBranches.has(branchId)) return reject("This sale's branch is not part of your business."); + + for (const line of sale.lines) { + if (!productMap.has(line.productId)) return reject("A product in this sale no longer exists."); + } + + // A bad phone number must not lose a sale that physically happened. + let customerPhone: string | null = null; + if (sale.customerPhone) { + try { + customerPhone = normalizeKenyanPhone(sale.customerPhone); + } catch { + customerPhone = null; + } + } + + let newLines = 0; + let amountTotal = 0; + let priceMismatch = false; + for (const [index, line] of sale.lines.entries()) { + // A product deactivated while the device was offline is still + // recorded at its price: the sale already happened. + const product = productMap.get(line.productId)!; + const unitPrice = overrideMap.get(`${branchId}:${line.productId}`) ?? product.unitPrice; + const lineTotal = Math.round(line.quantity * unitPrice * 100) / 100; + const mismatch = line.unitPrice !== undefined && line.unitPrice !== unitPrice; + + let txn: ITransaction; + try { + txn = await Transaction.create({ + businessId: auth.businessId, + branchId, + staffId: auth.sub, + customerName: sale.customerName || null, + customerPhone, + productId: product._id, + sizeLiters: product.sizeLiters, + quantity: line.quantity, + unitPrice, + amountTotal: lineTotal, + paymentMethod: "CASH", + paymentStatus: "SUCCESS", + clientUuid: `${sale.clientUuid}:${index}`, + saleId: sale.clientUuid, + lineIndex: index, + syncedFromOffline: !!sale.clientSubmittedAt, + clientSubmittedAt: sale.clientSubmittedAt ? new Date(sale.clientSubmittedAt) : null, + clientUnitPrice: line.unitPrice ?? null, + priceMismatch: mismatch, + }); + newLines += 1; + } catch (err: unknown) { + if ((err as { code?: number })?.code !== 11000) throw err; + // Already recorded by an earlier attempt: reuse it. + const existing = await Transaction.findOne({ clientUuid: `${sale.clientUuid}:${index}` }); + if (!existing || existing.businessId.toString() !== auth.businessId) { + return reject("This sale id is already in use."); + } + txn = existing; + } + // Idempotent, and also repairs a line whose stock movement was not + // written because an earlier attempt failed midway. + await recordSaleMovement(txn); + amountTotal += txn.amountTotal; + priceMismatch = priceMismatch || txn.priceMismatch; + } + + return { + clientUuid: sale.clientUuid, + status: newLines === 0 ? "duplicate" : "saved", + amountTotal: Math.round(amountTotal * 100) / 100, + priceMismatch, + }; + } + + return NextResponse.json({ results }); } diff --git a/src/lib/server/models/StockMovement.ts b/src/lib/server/models/StockMovement.ts new file mode 100644 index 0000000..52b3977 --- /dev/null +++ b/src/lib/server/models/StockMovement.ts @@ -0,0 +1,51 @@ +import { Schema, model, models, Model, Types, Document } from "mongoose"; + +export const MOVEMENT_TYPES = ["SALE", "PURCHASE", "TRANSFER_OUT", "TRANSFER_IN", "ADJUSTMENT"] as const; +export type MovementType = (typeof MOVEMENT_TYPES)[number]; + +export interface IStockMovement extends Document { + _id: Types.ObjectId; + businessId: Types.ObjectId; + branchId: Types.ObjectId; + productId: Types.ObjectId; + type: MovementType; + quantity: number; // signed: + adds stock to the branch, - removes it + refId: string; // the document that caused it (transaction, purchase, transfer, adjustment) + note: string | null; + recordedBy: Types.ObjectId | null; + createdAt: Date; // authoritative — server clock +} + +/** + * The stock ledger. A branch's stock of a product is the SUM of its + * movements — there is no separately stored balance that could drift out + * of step with history. + * + * Every movement names the document that caused it, and (type, refId) is + * unique. Writing a movement is therefore idempotent: replaying an offline + * sale, retrying a transfer, or a second M-Pesa callback can never deduct + * or add the same stock twice. A transfer writes two movements with the + * same refId but different types (TRANSFER_OUT, TRANSFER_IN). + */ +const stockMovementSchema = new Schema( + { + businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true }, + branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, + productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, + type: { type: String, enum: MOVEMENT_TYPES, required: true }, + quantity: { type: Number, required: true }, + refId: { type: String, required: true }, + note: { type: String, default: null, trim: true }, + recordedBy: { type: Schema.Types.ObjectId, ref: "User", default: null }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +stockMovementSchema.index({ type: 1, refId: 1 }, { unique: true }); +// Stock-level aggregation and the per-branch movement history. +stockMovementSchema.index({ businessId: 1, branchId: 1, productId: 1 }); +stockMovementSchema.index({ businessId: 1, branchId: 1, createdAt: -1 }); + +export const StockMovement = + (models.StockMovement as Model) || + model("StockMovement", stockMovementSchema); diff --git a/src/lib/server/models/StockTransfer.ts b/src/lib/server/models/StockTransfer.ts new file mode 100644 index 0000000..14856a2 --- /dev/null +++ b/src/lib/server/models/StockTransfer.ts @@ -0,0 +1,40 @@ +import { Schema, model, models, Model, Types, Document } from "mongoose"; + +export interface IStockTransfer extends Document { + _id: Types.ObjectId; + businessId: Types.ObjectId; + fromBranchId: Types.ObjectId; + toBranchId: Types.ObjectId; + productId: Types.ObjectId; + quantity: number; + clientUuid: string; // idempotency key from the form, so a double-submit moves stock once + note: string | null; + recordedBy: Types.ObjectId; + createdAt: Date; +} + +/** + * An inter-branch stock transfer. The stock itself moves through two + * StockMovement rows (TRANSFER_OUT at the source, TRANSFER_IN at the + * destination) whose refId is this transfer's id. + */ +const stockTransferSchema = new Schema( + { + businessId: { type: Schema.Types.ObjectId, ref: "Business", required: true }, + fromBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, + toBranchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, + productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, + quantity: { type: Number, required: true, min: 1 }, + clientUuid: { type: String, required: true }, + note: { type: String, default: null, trim: true }, + recordedBy: { type: Schema.Types.ObjectId, ref: "User", required: true }, + }, + { timestamps: { createdAt: true, updatedAt: false } } +); + +stockTransferSchema.index({ businessId: 1, clientUuid: 1 }, { unique: true }); +stockTransferSchema.index({ businessId: 1, createdAt: -1 }); + +export const StockTransfer = + (models.StockTransfer as Model) || + model("StockTransfer", stockTransferSchema); diff --git a/src/lib/server/models/Transaction.ts b/src/lib/server/models/Transaction.ts index 9b02ac8..ae15738 100644 --- a/src/lib/server/models/Transaction.ts +++ b/src/lib/server/models/Transaction.ts @@ -9,7 +9,7 @@ export interface ITransaction extends Document { branchId: Types.ObjectId; staffId: Types.ObjectId; customerName: string | null; - customerPhone: string; + customerPhone: string | null; // optional for cash sales; required for M-Pesa productId: Types.ObjectId; sizeLiters: number; // snapshotted at sale time quantity: number; @@ -26,8 +26,15 @@ export interface ITransaction extends Document { resultDesc: string | null; }; - clientUuid: string; // idempotency key generated on-device + clientUuid: string; // idempotency key for this line: `${saleId}:${lineIndex}` for cash sales + saleId: string | null; // groups the lines of one multi-item sale (the device's sale UUID) + lineIndex: number; syncedFromOffline: boolean; + // What the device showed and collected per unit. The server price stays + // authoritative; a difference (e.g. the price changed while the device + // was offline) is kept and flagged rather than silently overwritten. + clientUnitPrice: number | null; + priceMismatch: boolean; // TAMPER PREVENTION: this is what the device *claimed* the time was when // the sale happened — informational only, e.g. for offline-queue @@ -50,7 +57,7 @@ const transactionSchema = new Schema( branchId: { type: Schema.Types.ObjectId, ref: "Branch", required: true }, staffId: { type: Schema.Types.ObjectId, ref: "User", required: true }, customerName: { type: String, default: null, trim: true }, - customerPhone: { type: String, required: true }, + customerPhone: { type: String, default: null }, productId: { type: Schema.Types.ObjectId, ref: "Product", required: true }, sizeLiters: { type: Number, required: true }, quantity: { type: Number, required: true, min: 1 }, @@ -73,7 +80,11 @@ const transactionSchema = new Schema( }, clientUuid: { type: String, required: true }, + saleId: { type: String, default: null }, + lineIndex: { type: Number, default: 0 }, syncedFromOffline: { type: Boolean, default: false }, + clientUnitPrice: { type: Number, default: null }, + priceMismatch: { type: Boolean, default: false }, clientSubmittedAt: { type: Date, default: null }, }, { timestamps: true } // createdAt/updatedAt set from the server's Date.now(), not client input @@ -92,6 +103,7 @@ transactionSchema.index({ clientUuid: 1 }, { unique: true }); transactionSchema.index({ branchId: 1, createdAt: -1 }); transactionSchema.index({ businessId: 1, createdAt: -1 }); transactionSchema.index({ paymentStatus: 1 }); +transactionSchema.index({ businessId: 1, saleId: 1 }); export const Transaction = (models.Transaction as Model) || diff --git a/src/lib/server/stock.ts b/src/lib/server/stock.ts new file mode 100644 index 0000000..71418a4 --- /dev/null +++ b/src/lib/server/stock.ts @@ -0,0 +1,140 @@ +import mongoose, { Types } from "mongoose"; +import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement"; +import { Branch } from "@/lib/server/models/Branch"; +import { Product } from "@/lib/server/models/Product"; +import type { ITransaction } from "@/lib/server/models/Transaction"; + +type Id = Types.ObjectId | string; + +export interface MovementInput { + businessId: Id; + branchId: Id; + productId: Id; + type: MovementType; + quantity: number; // signed + refId: string; + note?: string | null; + recordedBy?: Id | null; +} + +/** + * Write one ledger movement, idempotently: if a movement with the same + * (type, refId) already exists nothing changes. Safe to call again on any + * retry or replay. + */ +export async function recordMovement(m: MovementInput): Promise { + await StockMovement.updateOne( + { type: m.type, refId: m.refId }, + { + $setOnInsert: { + businessId: m.businessId, + branchId: m.branchId, + productId: m.productId, + quantity: m.quantity, + note: m.note ?? null, + recordedBy: m.recordedBy ?? null, + }, + }, + { upsert: true } + ); +} + +/** + * Deduct a completed sale from its branch's stock. Only SUCCESS sales move + * stock — a PENDING or FAILED M-Pesa prompt never does. + */ +export async function recordSaleMovement( + txn: Pick +): Promise { + if (txn.paymentStatus !== "SUCCESS") return; + await recordMovement({ + businessId: txn.businessId, + branchId: txn.branchId, + productId: txn.productId, + type: "SALE", + quantity: -Math.abs(txn.quantity), + refId: txn._id.toString(), + recordedBy: txn.staffId, + }); +} + +/** Ledger refIds derived from client-generated keys are namespaced by tenant. */ +export function clientRefId(businessId: Id, clientUuid: string): string { + return `${businessId.toString()}:${clientUuid}`; +} + +/** Current stock of one product at one branch (the sum of its movements). */ +export async function stockOf(businessId: Id, branchId: Id, productId: Id): Promise { + const [row] = await StockMovement.aggregate<{ total: number }>([ + { + $match: { + businessId: new mongoose.Types.ObjectId(businessId.toString()), + branchId: new mongoose.Types.ObjectId(branchId.toString()), + productId: new mongoose.Types.ObjectId(productId.toString()), + }, + }, + { $group: { _id: null, total: { $sum: "$quantity" } } }, + ]); + return row?.total ?? 0; +} + +export type StockStatus = "OK" | "LOW" | "OUT"; + +export interface StockLevel { + branchId: string; + branchName: string; + productId: string; + productLabel: string; + unit: string; + quantity: number; + lowStockThreshold: number; + status: StockStatus; +} + +export function stockStatus(quantity: number, threshold: number): StockStatus { + if (quantity <= 0) return "OUT"; + if (quantity <= threshold) return "LOW"; + return "OK"; +} + +/** + * Stock of every active product at every branch in scope — including + * zero rows, so an owner sees "0" rather than a missing product. A + * negative quantity is reported as-is: it means sales were recorded + * (typically offline) for stock the ledger never received, and the branch + * needs a count/adjustment. + */ +export async function getStockLevels(businessId: Id, branchId?: Id | null): Promise { + const bizObj = new mongoose.Types.ObjectId(businessId.toString()); + const branchFilter: Record = { businessId: bizObj }; + if (branchId) branchFilter._id = new mongoose.Types.ObjectId(branchId.toString()); + + const [branches, products, totals] = await Promise.all([ + Branch.find(branchFilter).sort({ branchName: 1 }).lean(), + Product.find({ businessId: bizObj, isActive: true }).sort({ sortOrder: 1 }).lean(), + StockMovement.aggregate<{ _id: { branchId: Types.ObjectId; productId: Types.ObjectId }; total: number }>([ + { $match: branchId ? { businessId: bizObj, branchId: branchFilter._id } : { businessId: bizObj } }, + { $group: { _id: { branchId: "$branchId", productId: "$productId" }, total: { $sum: "$quantity" } } }, + ]), + ]); + + const totalByKey = new Map(totals.map((t) => [`${t._id.branchId}:${t._id.productId}`, t.total])); + const levels: StockLevel[] = []; + for (const b of branches) { + for (const p of products) { + const quantity = totalByKey.get(`${b._id}:${p._id}`) ?? 0; + const lowStockThreshold = p.lowStockThreshold ?? 10; + levels.push({ + branchId: b._id.toString(), + branchName: b.branchName, + productId: p._id.toString(), + productLabel: p.label, + unit: p.unit ?? "L", + quantity, + lowStockThreshold, + status: stockStatus(quantity, lowStockThreshold), + }); + } + } + return levels; +} diff --git a/src/lib/syncProtocol.ts b/src/lib/syncProtocol.ts new file mode 100644 index 0000000..5f1a5e8 --- /dev/null +++ b/src/lib/syncProtocol.ts @@ -0,0 +1,11 @@ +// Shared between the terminal's offline queue and POST /api/transactions/cash. + +/** Largest batch the offline queue sends in one request. */ +export const MAX_SALES_PER_REQUEST = 50; + +/** Largest number of product lines in one sale. */ +export const MAX_LINES_PER_SALE = 20; + +export type SaleResult = + | { clientUuid: string; status: "saved" | "duplicate"; amountTotal: number; priceMismatch: boolean } + | { clientUuid: string; status: "rejected"; error: string }; diff --git a/tests/stock.test.ts b/tests/stock.test.ts new file mode 100644 index 0000000..fde6fde --- /dev/null +++ b/tests/stock.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { stockOf } from "@/lib/server/stock"; +import * as stock from "@/app/api/stock/route"; +import * as adjustments from "@/app/api/stock/adjustments/route"; +import * as transfers from "@/app/api/stock/transfers/route"; +import * as purchases from "@/app/api/purchases/route"; +import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; + +async function addStock(t: Tenant, as: Actor, branchId: string, productId: string, quantity: number) { + const res = await adjustments.POST( + req("POST", "/x", { as, body: { clientUuid: uuid(), branchId, productId, mode: "DELTA", quantity, note: "opening" } }) + ); + expect(res.status).toBe(201); +} + +describe("stock ledger", () => { + it("adds purchased stock to the receiving branch", async () => { + const t = await makeTenant(); + const res = await purchases.POST( + req("POST", "/x", { + as: t.managerA, + body: { supplierName: "Kiwanja Water", productId: t.water20, quantity: 40, unitCost: 120 }, + }) + ); + expect(res.status).toBe(201); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(40); + }); + + it("corrects stock to a physical count, once per submit", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 50); + const body = { clientUuid: uuid(), productId: t.water20, mode: "COUNT", quantity: 47, note: "evening count" }; + const first = await json(await adjustments.POST(req("POST", "/x", { as: t.managerA, body }))); + expect(first.body.quantity).toBe(47); + // A double-submit of the same count changes nothing further. + const again = await json(await adjustments.POST(req("POST", "/x", { as: t.managerA, body }))); + expect(again.body.quantity).toBe(47); + }); + + it("does not let a manager adjust another branch", async () => { + const t = await makeTenant(); + const res = await adjustments.POST( + req("POST", "/x", { + as: t.managerA, + body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 5 }, + }) + ); + expect(res.status).toBe(403); + }); + + it("reports stock per branch with low and out-of-stock flags", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 8); // threshold defaults to 10 + await addStock(t, t.owner, t.branchA, t.water5, 30); + const { body } = await json(await stock.GET(req("GET", "/api/stock", { as: t.owner }))); + const find = (b: string, p: string) => body.levels.find((l: any) => l.branchId === b && l.productId === p); + expect(body.levels).toHaveLength(4); // 2 branches x 2 products, zero rows included + expect(find(t.branchA, t.water20)).toMatchObject({ quantity: 8, status: "LOW" }); + expect(find(t.branchA, t.water5)).toMatchObject({ quantity: 30, status: "OK" }); + expect(find(t.branchB, t.water20)).toMatchObject({ quantity: 0, status: "OUT" }); + }); + + it("shows a cashier only their own branch's stock", async () => { + const t = await makeTenant(); + const { body } = await json(await stock.GET(req("GET", "/api/stock?branchId=" + t.branchB, { as: t.cashierA }))); + expect(new Set(body.levels.map((l: any) => l.branchId))).toEqual(new Set([t.branchA])); + }); +}); + +describe("inter-branch transfers", () => { + it("moves stock out of one branch and into the other", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 30); + const res = await transfers.POST( + req("POST", "/x", { + as: t.owner, + body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 12 }, + }) + ); + expect(res.status).toBe(201); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(18); + expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(12); + }); + + it("applies a double-submitted transfer once", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 30); + const body = { clientUuid: uuid(), toBranchId: t.branchB, productId: t.water20, quantity: 10 }; + await transfers.POST(req("POST", "/x", { as: t.managerA, body })); + const second = await transfers.POST(req("POST", "/x", { as: t.managerA, body })); + expect(second.status).toBe(200); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20); + expect(await stockOf(t.businessId, t.branchB, t.water20)).toBe(10); + }); + + it("refuses to send more than the branch has", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchA, t.water20, 5); + const res = await transfers.POST( + req("POST", "/x", { + as: t.managerA, + body: { clientUuid: uuid(), toBranchId: t.branchB, productId: t.water20, quantity: 6 }, + }) + ); + expect(res.status).toBe(409); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(5); + }); + + it("only lets a manager send from their own branch", async () => { + const t = await makeTenant(); + await addStock(t, t.owner, t.branchB, t.water20, 5); + const res = await transfers.POST( + req("POST", "/x", { + as: t.managerA, + body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 1 }, + }) + ); + expect(res.status).toBe(403); + }); + + it("cannot move stock into another tenant's branch", async () => { + const t = await makeTenant("Alpha"); + const other = await makeTenant("Beta"); + await addStock(t, t.owner, t.branchA, t.water20, 5); + const res = await transfers.POST( + req("POST", "/x", { + as: t.owner, + body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: other.branchA, productId: t.water20, quantity: 1 }, + }) + ); + expect(res.status).toBe(404); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(5); + }); +}); diff --git a/tests/sync.test.ts b/tests/sync.test.ts new file mode 100644 index 0000000..dc0c19b --- /dev/null +++ b/tests/sync.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { Transaction } from "@/lib/server/models/Transaction"; +import { Product, BranchPricing } from "@/lib/server/models/Product"; +import { stockOf } from "@/lib/server/stock"; +import * as cash from "@/app/api/transactions/cash/route"; +import type { SaleResult } from "@/lib/syncProtocol"; +import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers"; + +function sale(t: Tenant, cashier: Actor, lines: { productId: string; quantity: number; unitPrice?: number }[], extra = {}) { + return { + clientUuid: uuid(), + staffId: cashier.id, + branchId: cashier.branchId!, + clientSubmittedAt: new Date().toISOString(), + lines, + ...extra, + }; +} + +async function post(as: Actor, sales: unknown[]) { + return json<{ results: SaleResult[] }>(await cash.POST(req("POST", "/api/transactions/cash", { as, body: { sales } }))); +} + +describe("offline sale sync", () => { + it("records a multi-item sale as one line per product and deducts each from stock", async () => { + const t = await makeTenant(); + const s = sale(t, t.cashierA, [ + { productId: t.water20, quantity: 2, unitPrice: 200 }, + { productId: t.water5, quantity: 3, unitPrice: 60 }, + ]); + const { status, body } = await post(t.cashierA, [s]); + expect(status).toBe(200); + expect(body.results).toEqual([{ clientUuid: s.clientUuid, status: "saved", amountTotal: 580, priceMismatch: false }]); + expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(2); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-2); + expect(await stockOf(t.businessId, t.branchA, t.water5)).toBe(-3); + }); + + it("reports a replayed sale as duplicate, so the device can stop resending it", async () => { + const t = await makeTenant(); + const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); + await post(t.cashierA, [s]); + const replay = await post(t.cashierA, [s]); + expect(replay.body.results[0]).toMatchObject({ clientUuid: s.clientUuid, status: "duplicate" }); + expect(await Transaction.countDocuments({ saleId: s.clientUuid })).toBe(1); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-1); + }); + + it("syncs a 120-sale backlog in chunks with no duplicates (the old 100-row dead end)", async () => { + const t = await makeTenant(); + const backlog = Array.from({ length: 120 }, () => sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }])); + // An over-sized batch is refused outright... + const tooBig = await cash.POST(req("POST", "/x", { as: t.cashierA, body: { sales: backlog } })); + expect(tooBig.status).toBe(400); + // ...and the device sends it in chunks of 50, twice over (a full replay). + for (let pass = 0; pass < 2; pass++) { + for (let i = 0; i < backlog.length; i += 50) { + const { status } = await post(t.cashierA, backlog.slice(i, i + 50)); + expect(status).toBe(200); + } + } + expect(await Transaction.countDocuments({ businessId: t.businessId })).toBe(120); + expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(-120); + }); + + it("rejects one bad sale without blocking the rest of the batch", async () => { + const t = await makeTenant(); + const good = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); + const malformed = { clientUuid: uuid(), lines: [] }; + const { body } = await post(t.cashierA, [malformed, good]); + const byId = Object.fromEntries(body.results.map((r) => [r.clientUuid, r.status])); + expect(byId[malformed.clientUuid]).toBe("rejected"); + expect(byId[good.clientUuid]).toBe("saved"); + }); + + it("refuses to credit a queued sale to whoever happens to be signed in", async () => { + const t = await makeTenant(); + const otherCashiersSale = sale(t, t.cashierB, [{ productId: t.water20, quantity: 1 }]); + const { body } = await post(t.cashierA, [otherCashiersSale]); + expect(body.results[0].status).toBe("rejected"); + expect(await Transaction.countDocuments({})).toBe(0); + }); + + it("keeps a sale at the branch where it happened after the cashier is moved", async () => { + const t = await makeTenant(); + const s = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); // recorded at branch A + const moved = { ...t.cashierA, branchId: t.branchB }; + await post(moved, [s]); + const txn = await Transaction.findOne({ saleId: s.clientUuid }); + expect(txn!.branchId.toString()).toBe(t.branchA); + }); + + it("rejects a sale naming another tenant's product or branch", async () => { + const t = await makeTenant("Alpha"); + const other = await makeTenant("Beta"); + const foreignProduct = sale(t, t.cashierA, [{ productId: other.water20, quantity: 1 }]); + const foreignBranch = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { branchId: other.branchA }); + const { body } = await post(t.cashierA, [foreignProduct, foreignBranch]); + expect(body.results.map((r) => r.status)).toEqual(["rejected", "rejected"]); + expect(await Transaction.countDocuments({})).toBe(0); + }); + + it("uses the branch price, flags a price the device showed differently, and keeps deactivated products sellable", async () => { + const t = await makeTenant(); + await BranchPricing.create({ businessId: t.businessId, branchId: t.branchA, productId: t.water20, unitPrice: 180 }); + await Product.updateOne({ _id: t.water5 }, { isActive: false }); + const s = sale(t, t.cashierA, [ + { productId: t.water20, quantity: 1, unitPrice: 200 }, // stale price on the device + { productId: t.water5, quantity: 1, unitPrice: 60 }, + ]); + const { body } = await post(t.cashierA, [s]); + expect(body.results[0]).toMatchObject({ status: "saved", amountTotal: 240, priceMismatch: true }); + }); + + it("accepts a cash sale with no customer phone, and keeps one with a bad phone", async () => { + const t = await makeTenant(); + const noPhone = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }]); + const badPhone = sale(t, t.cashierA, [{ productId: t.water20, quantity: 1 }], { customerPhone: "12" }); + const { body } = await post(t.cashierA, [noPhone, badPhone]); + expect(body.results.map((r) => r.status)).toEqual(["saved", "saved"]); + }); + + it("still accepts the old single-product payload", async () => { + const t = await makeTenant(); + const legacy = { clientUuid: uuid(), customerPhone: "0712345678", productId: t.water20, quantity: 2 }; + const { body } = await post(t.cashierA, [legacy]); + expect(body.results[0]).toMatchObject({ status: "saved", amountTotal: 400 }); + }); +});