ff75cf2fa7
Cost of goods sold used each product's average buying price across the whole business. Ten jerrycans bought at 100 and ten at 140, with 15 sold, cost 1,800 instead of the 1,700 actually paid for those units; a branch that bought dearer was costed like one that bought cheaper; and units moved between branches lost their cost. src/lib/server/costing.ts replays the stock ledger in order with cost layers per branch and product: - purchases add stock at their unit cost; transfers carry the sending branch's layers to the receiving branch; opening stock and upward counts are valued at the product's purchase price at the time; - sales take the oldest layers first, and that is cost of goods sold; - stock written off by counts or adjustments is a separate "stock losses" figure, now deducted in net profit; - a sale recorded before its stock (stock gone negative) is costed by the next stock to arrive at that branch, even after the period ended; until then it is reported as units with no purchase cost on record. The report shows the method and a Stock losses card. Sales recorded before the stock ledger existed are counted as uncosted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
6.0 KiB
TypeScript
149 lines
6.0 KiB
TypeScript
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 };
|
|
}
|