import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { connectDb } from "@/lib/server/db"; import { Transaction, ITransaction } from "@/lib/server/models/Transaction"; import { Product, BranchPricing } from "@/lib/server/models/Product"; 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 lineSchema = z.object({ productId: objectId, quantity: z.number().int().min(1).max(500), unitPrice: z.number().nonnegative().optional(), // what the device showed; informational }); 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 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] }; const parsed = bodySchema.safeParse(normalized); if (!parsed.success) { return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid request" }, { status: 400 }); } if (!auth.branchId) { return NextResponse.json({ error: "No active branch assignment for this account" }, { status: 403 }); } 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" }); } } // 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}`, businessId: auth.businessId }); if (!existing) 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 }); }