Cost sales first-in-first-out per branch instead of at an average

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>
This commit is contained in:
2026-10-12 12:08:15 +03:00
parent db9387c01a
commit ff75cf2fa7
6 changed files with 362 additions and 31 deletions
+15 -4
View File
@@ -18,6 +18,7 @@ interface ProfitReport {
unitsSold: number;
costOfGoodsSold: number;
unitsWithoutCost: number;
stockLosses: number;
grossProfit: number;
expenses: number;
expenseCount: number;
@@ -121,8 +122,9 @@ export default function ReportsPage() {
[" M-Pesa", report.mpesaTotal],
["Sales count", report.salesCount],
["Units sold", report.unitsSold],
["Cost of goods sold (average purchase cost)", report.costOfGoodsSold],
["Cost of goods sold (FIFO per branch)", report.costOfGoodsSold],
["Units sold with no purchase cost on record", report.unitsWithoutCost],
["Stock losses (counts and write-offs)", report.stockLosses],
["Gross profit", report.grossProfit],
["Expenses", report.expenses],
["Estimated net profit", report.netProfit],
@@ -248,7 +250,7 @@ export default function ReportsPage() {
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoodsSold)}</p>
<p className="mt-1 text-xs text-depth-500">
{report.unitsSold} units sold x average buying price
{report.unitsSold} units sold, each at what its branch paid for its oldest stock (first in, first out)
{report.unitsWithoutCost > 0 && ` · ${report.unitsWithoutCost} units have no purchase cost on record`}
</p>
</div>
@@ -272,11 +274,11 @@ export default function ReportsPage() {
>
{money(report.netProfit)}
</p>
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses</p>
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses − stock losses</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Stock purchased</p>
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
@@ -290,6 +292,15 @@ export default function ReportsPage() {
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</p>
<p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
</div>
<div className="rounded-xl2 bg-white p-5 shadow-soft">
<p className="text-xs uppercase tracking-wide text-depth-500">Stock losses</p>
<p className={"mt-1 font-display text-2xl font-bold " + (report.stockLosses > 0 ? "text-alert-600" : "text-depth-900")}>
{money(report.stockLosses)}
</p>
<p className="mt-1 text-xs text-depth-500">
Cost of stock written off by counts, breakage or adjustments. Deducted in net profit.
</p>
</div>
</div>
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
+18 -19
View File
@@ -6,6 +6,7 @@ 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";
@@ -15,11 +16,13 @@ export const dynamic = "force-dynamic";
*
* 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)
* 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
* Net profit gross profit - expenses
* 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
@@ -47,7 +50,7 @@ export async function GET(req: NextRequest) {
const branchObjId = branchId ? new mongoose.Types.ObjectId(branchId) : null;
const inPeriod = { businessId: businessObjId, createdAt: { $gte: from, $lt: to }, ...(branchObjId ? { branchId: branchObjId } : {}) };
const [salesByProduct, avgCosts, purchaseAgg, expenseAgg] = await Promise.all([
const [salesByProduct, cost, purchaseAgg, expenseAgg] = await Promise.all([
Transaction.aggregate<{
_id: Types.ObjectId;
revenue: number;
@@ -68,12 +71,7 @@ export async function GET(req: NextRequest) {
},
},
]),
// 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" } } },
]),
fifoCost(auth.businessId, { from, to, branchId }),
Purchase.aggregate<{ total: number; count: number }>([
{ $match: inPeriod },
{ $group: { _id: null, total: { $sum: "$totalCost" }, count: { $sum: 1 } } },
@@ -84,19 +82,18 @@ export async function GET(req: NextRequest) {
]),
]);
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;
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 unitCost = avgCost.get(s._id.toString());
if (unitCost === undefined) unitsWithoutCost += s.units;
else costOfGoodsSold += s.units * unitCost;
}
costOfGoodsSold = Math.round(costOfGoodsSold * 100) / 100;
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;
@@ -111,13 +108,15 @@ export async function GET(req: NextRequest) {
salesCount,
unitsSold,
costOfGoodsSold,
// Units sold of products never bought through Purchases: their cost
// is unknown, so gross profit overstates by their cost.
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) * 100) / 100,
netProfit: Math.round((grossProfit - expenses - cost.stockLosses) * 100) / 100,
stockPurchases,
purchaseCount: purchaseAgg[0]?.count ?? 0,
cashSurplus: Math.round((revenue - stockPurchases - expenses) * 100) / 100,