import { NextRequest, NextResponse } from "next/server"; import mongoose, { Types } from "mongoose"; import { connectDb } from "@/lib/server/db"; import { Transaction } from "@/lib/server/models/Transaction"; import { Purchase } from "@/lib/server/models/Purchase"; import { Expense } from "@/lib/server/models/Expense"; import { authorize, badRequest, branchParam, MANAGERS } from "@/lib/server/auth"; import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay"; import { costOfGoodsSold as fifoCost } from "@/lib/server/costing"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** * GET /api/reports/profit?branchId=&fromDate=YYYY-MM-DD&toDate=YYYY-MM-DD * * Management overview, NOT accounting (full accounting is out of the PRD's scope). * Revenue SUCCESS sales in the period * Cost of goods sold first-in-first-out per branch: each unit sold costs * what that branch paid for its oldest stock on hand * (see src/lib/server/costing.ts) * Gross profit revenue - cost of goods sold * Expenses operating expenses in the period * Stock losses cost of stock written off by counts and adjustments * Net profit gross profit - expenses - stock losses * Shown separately, because buying stock is not the same as using it: * Stock purchased cash spent on stock in the period * Cash surplus revenue - stock purchased - expenses * * Dates are Nairobi business days, inclusive. `from`/`to` (ISO instants) * are still accepted for older clients. */ export async function GET(req: NextRequest) { await connectDb(); // Revenue, costs and profit are owner/manager information, never a cashier's. const gate = authorize(req, MANAGERS); if (!gate.ok) return gate.response; const auth = gate.auth; const url = new URL(req.url); const scope = branchParam(auth, url.searchParams.get("branchId")); if (!scope.ok) return scope.response; const branchId = scope.branchId; const range = parseRange(url.searchParams); if ("error" in range) return badRequest(range.error); const { from, to } = range; const businessObjId = new mongoose.Types.ObjectId(auth.businessId); const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null; const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) }; const [salesByProduct, cost, purchaseAgg, expenseAgg] = await Promise.all([ Transaction.aggregate<{ _id: Types.ObjectId; revenue: number; cashTotal: number; mpesaTotal: number; units: number; lines: number; }>([ { $match: { ...inPeriod, paymentStatus: "SUCCESS" } }, { $group: { _id: "$productId", revenue: { $sum: "$amountTotal" }, cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } }, mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } }, units: { $sum: "$quantity" }, lines: { $sum: 1 }, }, }, ]), fifoCost(auth.businessId, { from, to, branchId }), Purchase.aggregate<{ total: number; count: number }>([ { $match: inPeriod }, { $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } }, ]), Expense.aggregate<{ total: number; count: number }>([ { $match: inPeriod }, { $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } }, ]), ]); let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0; for (const s of salesByProduct) { revenue += s.revenue; cashTotal += s.cashTotal; mpesaTotal += s.mpesaTotal; salesCount += s.lines; unitsSold += s.units; } const costOfGoodsSold = cost.costOfGoodsSold; // Sales recorded before the stock ledger existed have no stock movement, // so FIFO cannot cost them: count them as uncosted too. const unitsWithoutCost = Math.max(cost.unitsWithoutCost, unitsSold - cost.unitsCosted); const stockPurchases = purchaseAgg[0]?.total ?? 0; const expenses = expenseAgg[0]?.total ?? 0; const grossProfit = Math.round((revenue - costOfGoodsSold) * 100) / 100; return NextResponse.json({ scope: branchId ? "branch" : "global", range: { from: from.toISOString(), to: to.toISOString() }, revenue, cashTotal, mpesaTotal, salesCount, unitsSold, costOfGoodsSold, costMethod: "FIFO per branch", // Units sold whose cost is unknown (no purchase price for that stock on // record): gross profit overstates by their cost. unitsWithoutCost, stockLosses: cost.stockLosses, grossProfit, expenses, expenseCount: expenseAgg[0]?.count ?? 0, netProfit: Math.round((grossProfit - expenses - cost.stockLosses) * 100) / 100, stockPurchases, purchaseCount: purchaseAgg[0]?.count ?? 0, cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100, }); } /** The half-open UTC range [from, to) for the request, or a validation error. */ function parseRange(params: URLSearchParams): { from: Date; to: Date } | { error: string } { const fromDate = params.get("fromDate"); const toDate = params.get("toDate"); let from: Date; let to: Date; if (fromDate || toDate) { if ((fromDate && !isBusinessDate(fromDate)) || (toDate && !isBusinessDate(toDate))) { return { error: "Dates must be YYYY-MM-DD" }; } from = fromDate ? dayRangeNairobi(fromDate).start : startOfMonthNairobi(); to = toDate ? dayRangeNairobi(toDate).end : new Date(); } else { from = params.get("from") ? new Date(params.get("from")!) : startOfMonthNairobi(); to = params.get("to") ? new Date(params.get("to")!) : new Date(); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return { error: "Invalid from/to date" }; } // A reversed period must not look like a period with no activity. if (from >= to) return { error: "The start date must be on or before the end date." }; return { from, to }; }