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
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import mongoose from "mongoose";
import { costOfGoodsSold } from "@/lib/server/costing";
import { StockMovement } from "@/lib/server/models/StockMovement";
import { Transaction } from "@/lib/server/models/Transaction";
import { Purchase } from "@/lib/server/models/Purchase";
import { startOfMonthNairobi } from "@/lib/server/businessDay";
import * as cash from "@/app/api/transactions/cash/route";
import * as purchases from "@/app/api/purchases/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import * as profit from "@/app/api/reports/profit/route";
import { json, makeTenant, req, uuid, type Actor, type Tenant } from "./helpers";
const ALL_TIME = { from: new Date(0), to: new Date(Date.now() + 86_400_000) };
const buy = (t: Tenant, branchId: string, quantity: number, unitCost: number, productId = t.water20) =>
purchases.POST(req("POST", "/x", { as: t.owner, body: { branchId, supplierName: "Supplier", productId, quantity, unitCost } }));
const sell = (cashier: Actor, quantity: number, productId: string) =>
cash.POST(req("POST", "/x", { as: cashier, body: { sales: [{ clientUuid: uuid(), lines: [{ productId, quantity }] }] } }));
const adjust = (t: Tenant, mode: "COUNT" | "DELTA", quantity: number) =>
adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water20, mode, quantity } })
);
/** Move everything recorded so far to `when` (tests need history in an earlier period). */
async function backdate(t: Tenant, when: Date) {
// The raw collection: Mongoose treats createdAt as immutable.
const filter = { businessId: new mongoose.Types.ObjectId(t.businessId) };
await StockMovement.collection.updateMany(filter, { $set: { createdAt: when } });
await Transaction.collection.updateMany(filter, { $set: { createdAt: when } });
await Purchase.collection.updateMany(filter, { $set: { createdAt: when } });
}
describe("cost of goods sold: FIFO per branch", () => {
it("costs the oldest stock first, not an average", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await buy(t, t.branchA, 10, 20);
await sell(t.cashierA, 15, t.water20);
const c = await costOfGoodsSold(t.businessId, ALL_TIME);
expect(c).toMatchObject({ costOfGoodsSold: 200, unitsCosted: 15, unitsWithoutCost: 0 }); // 10x10 + 5x20 (the average would say 225)
});
it("costs each branch at what that branch paid", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await buy(t, t.branchB, 10, 30);
await sell(t.cashierB, 2, t.water20);
await sell(t.cashierA, 1, t.water20);
expect((await costOfGoodsSold(t.businessId, ALL_TIME)).costOfGoodsSold).toBe(70);
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchA })).costOfGoodsSold).toBe(10);
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchB })).costOfGoodsSold).toBe(60);
});
it("carries the sending branch's cost with a transfer", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchA, toBranchId: t.branchB, productId: t.water20, quantity: 5 },
})
);
await buy(t, t.branchB, 5, 30);
await sell(t.cashierB, 6, t.water20);
// B's oldest stock is the 5 transferred at 10, then 1 of its own at 30.
expect((await costOfGoodsSold(t.businessId, { ...ALL_TIME, branchId: t.branchB })).costOfGoodsSold).toBe(80);
});
it("costs a sale made before its stock was recorded by the stock that arrives next", async () => {
const t = await makeTenant();
await sell(t.cashierA, 3, t.water20); // offline sale, nothing on the books yet
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 0, unitsWithoutCost: 3 });
await buy(t, t.branchA, 10, 12);
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 36, unitsWithoutCost: 0 });
});
it("reports written-off stock as a loss, not as cost of goods sold, and deducts it in net profit", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await adjust(t, "COUNT", 8);
expect(await costOfGoodsSold(t.businessId, ALL_TIME)).toMatchObject({ costOfGoodsSold: 0, stockLosses: 20 });
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({ stockLosses: 20, grossProfit: 0, netProfit: -20 });
});
it("values opening stock at the product's purchase price", async () => {
const t = await makeTenant();
await adjust(t, "DELTA", 10); // opening stock, before any purchase was recorded
await buy(t, t.branchA, 10, 15);
await sell(t.cashierA, 12, t.water20);
expect((await costOfGoodsSold(t.businessId, ALL_TIME)).costOfGoodsSold).toBe(180);
});
it("counts only the period's sales, costed against what was left from earlier periods", async () => {
const t = await makeTenant();
await buy(t, t.branchA, 10, 10);
await sell(t.cashierA, 6, t.water20);
const lastMonth = new Date(startOfMonthNairobi().getTime() - 5 * 86_400_000);
await backdate(t, lastMonth);
await buy(t, t.branchA, 10, 20);
await sell(t.cashierA, 6, t.water20); // 4 left from last month at 10, then 2 at 20
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({ costMethod: "FIFO per branch", unitsSold: 6, costOfGoodsSold: 80, unitsWithoutCost: 0 });
});
});