Address the 11 October test report findings

The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.

- F-01: a business on its free trial may run 2 active branches whatever
  its tier, so an owner can try transfers and per-branch stock. Only
  active branches count towards any limit.
- F-06: owners can rename branches and deactivate/reactivate them from
  the Branches page. A branch can only be deactivated once it holds no
  stock and no active staff; its history stays in every report, and it
  leaves every picker.
- F-04: the Products page lists deactivated products under a toggle with
  Reactivate. Deactivating warns about stock still on hand, which stays
  on the Stock page so it can be transferred or counted out.
- F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of
  everything being litres.
- CAT-12: product prices must be above zero and names are unique per
  business, ignoring case.
- F-03: a reversed report period is refused by the page and the API, and
  the page labels which period the cards show. Report dates are Nairobi
  business days.
- F-07: cost of goods sold is units sold x average buying price. Stock
  bought in the period is reported separately, with a cash surplus line,
  so unsold stock no longer shows as a loss.
- STA-04: staff and new branch managers can be invited with a one-time,
  7-day link; the person sets their own password. Managers can issue a
  new link for their own cashiers.
- OFF-02 / POS-02: the till shows each product's stock and deducts every
  sale immediately, online or offline; it warns before selling more than
  the recorded stock, and has a Clear sale button.
- npm run backfill:stock turns purchases recorded before the stock ledger
  into stock, so existing tenants (and the report's ZERRO fixtures) start
  from what they bought.

tests/retest-plan.test.ts runs the report's completion and retest plan
and checks every balance and total it predicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:29:21 +03:00
parent cb962466b3
commit f865837b27
42 changed files with 1486 additions and 193 deletions
+103 -44
View File
@@ -1,21 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import mongoose from "mongoose";
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 { startOfMonthNairobi } from "@/lib/server/businessDay";
import { dayRangeNairobi, isBusinessDate, startOfMonthNairobi } from "@/lib/server/businessDay";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Management / profit overview — NOT full accounting.
// Revenue = sum of SUCCESS sales in range
// Cost of Goods= sum of purchase cost in range (stock bought in)
// Gross Profit = Revenue - Cost of Goods
// Expenses = sum of operating expenses in range
// Net Profit = Gross Profit - Expenses
/**
* 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 units sold x that product's average purchase cost
* (all purchases up to the end of the period)
* Gross profit revenue - cost of goods sold
* Expenses operating expenses in the period
* Net profit gross profit - expenses
* 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.
@@ -28,63 +39,111 @@ export async function GET(req: NextRequest) {
if (!scope.ok) return scope.response;
const branchId = scope.branchId;
// Default range: start of the current business month (EAT) -> now.
const now = new Date();
const from = url.searchParams.get("from") ? new Date(url.searchParams.get("from")!) : startOfMonthNairobi(now);
const to = url.searchParams.get("to") ? new Date(url.searchParams.get("to")!) : now;
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return badRequest("Invalid from/to date");
const range = parseRange(url.searchParams);
if ("error" in range) return badRequest(range.error);
const { from, to } = range;
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const businessObjId = new mongoose.Types.ObjectId(auth.businessId);
const dateMatch = { createdAt: { $gte: from, $lte: to } };
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
const salesMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId, paymentStatus: "SUCCESS" };
if (branchObjId) salesMatch.branchId = branchObjId;
const costMatch: Record<string, unknown> = { ...dateMatch, businessId: businessObjId };
if (branchObjId) costMatch.branchId = branchObjId;
const [salesAgg, purchaseAgg, expenseAgg] = await Promise.all([
Transaction.aggregate([
{ $match: salesMatch },
const [salesByProduct, avgCosts, 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: null,
_id: "$productId",
revenue: { $sum: "$amountTotal" },
cashTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "CASH"] }, "$amountTotal", 0] } },
mpesaTotal: { $sum: { $cond: [{ $eq: ["$paymentMethod", "MPESA"] }, "$amountTotal", 0] } },
salesCount: { $sum: 1 },
units: { $sum: "$quantity" },
lines: { $sum: 1 },
},
},
]),
Purchase.aggregate([
{ $match: costMatch },
{ $group: { _id: null, costOfGoods: { $sum: "$totalCost" }, purchaseCount: { $sum: 1 } } },
// Average buying price per product across the business (suppliers
// charge every branch alike), using everything bought up to period end.
Purchase.aggregate<{ _id: Types.ObjectId; cost: number; qty: number }>([
{ $match: { businessId: businessObjId, productId: { $ne: null }, createdAt: { $lt: to } } },
{ $group: { _id: "$productId", cost: { $sum: "$totalCost" }, qty: { $sum: "$quantity" } } },
]),
Expense.aggregate([
{ $match: costMatch },
{ $group: { _id: null, expenses: { $sum: "$amount" }, expenseCount: { $sum: 1 } } },
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 } } },
]),
]);
const revenue = salesAgg[0]?.revenue ?? 0;
const costOfGoods = purchaseAgg[0]?.costOfGoods ?? 0;
const expenses = expenseAgg[0]?.expenses ?? 0;
const grossProfit = revenue - costOfGoods;
const netProfit = grossProfit - expenses;
const avgCost = new Map(avgCosts.filter((c) => c.qty > 0).map((c) => [c._id.toString(), c.cost / c.qty]));
let revenue = 0, cashTotal = 0, mpesaTotal = 0, salesCount = 0, unitsSold = 0, unitsWithoutCost = 0, costOfGoodsSold = 0;
for (const s of salesByProduct) {
revenue += s.revenue;
cashTotal += s.cashTotal;
mpesaTotal += s.mpesaTotal;
salesCount += s.lines;
unitsSold += s.units;
const unitCost = avgCost.get(s._id.toString());
if (unitCost === undefined) unitsWithoutCost += s.units;
else costOfGoodsSold += s.units * unitCost;
}
costOfGoodsSold = Math.round(costOfGoodsSold * 100) / 100;
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: salesAgg[0]?.cashTotal ?? 0,
mpesaTotal: salesAgg[0]?.mpesaTotal ?? 0,
salesCount: salesAgg[0]?.salesCount ?? 0,
costOfGoods,
purchaseCount: purchaseAgg[0]?.purchaseCount ?? 0,
cashTotal,
mpesaTotal,
salesCount,
unitsSold,
costOfGoodsSold,
// Units sold of products never bought through Purchases: their cost
// is unknown, so gross profit overstates by their cost.
unitsWithoutCost,
grossProfit,
expenses,
expenseCount: expenseAgg[0]?.expenseCount ?? 0,
netProfit,
expenseCount: expenseAgg[0]?.count ?? 0,
netProfit: Math.round((grossProfit - expenses) * 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 };
}