diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md
index 286d436..a7e7826 100644
--- a/docs/PILOT_READINESS.md
+++ b/docs/PILOT_READINESS.md
@@ -98,7 +98,7 @@ seven findings and a retest plan. All of them are addressed on
| F-04 No way back for deactivated products | Products page lists deactivated products under a toggle, with Reactivate. Deactivating warns about stock still on hand; that stock stays on the Stock page and can be transferred or counted out. |
| F-05 Eggs shown as litres | Products carry a unit (L, ml, kg, g, pcs, tray, pack), chosen when the product is created and shown everywhere. |
| F-06 No branch or staff removal | Staff: deactivate/reactivate (previous change). Branches: rename and deactivate/reactivate on the Branches page. A branch can only be deactivated once it has no stock and no active staff; its history stays in reports. |
-| F-07 Purchases shown as cost of goods | Cost of goods sold is now units sold x the product's average buying price. Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
+| F-07 Purchases shown as cost of goods | Cost of goods sold covers only units sold (now first-in-first-out per branch; see "Cost of goods sold" below). Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
| STA-04 No invites | Staff and new branch managers can be invited: the owner or manager gets a one-time link (7 days) to send, and the person sets their own password. A new link can be made from Staff. |
| CAT-12 Zero price, duplicates | Prices must be above zero; product names are unique per business, ignoring case. |
| OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
@@ -181,10 +181,27 @@ business's data.
- Not guarded, by design: Business (the tenant itself, always looked up by
the session's id), UserSession, MpesaCallbackLog and StockLock.
+## Cost of goods sold
+
+The profit report costs sales first-in-first-out, per branch
+(`src/lib/server/costing.ts`). It replays the stock ledger in order. Each
+branch keeps queues of stock at the price it was bought at, and a sale
+takes the oldest first.
+
+- **Transfers carry their cost.** Stock moved between branches arrives at
+ what the sending branch paid.
+- **Opening stock and upward counts** are valued at the product's purchase
+ price at the time (or its first purchase price, if they came first).
+- **A sale recorded before its stock** (e.g. an offline sale before the
+ purchase was entered) is costed by the next stock to arrive at that
+ branch. Until then it counts as "units with no purchase cost on record".
+- **Stock written off** by counts or adjustments is shown as "Stock losses"
+ and deducted in net profit. It is not part of cost of goods sold.
+- Each report replays the business's whole stock history. That is fast at
+ pilot volumes; a business with years of history would want stored cost
+ layers instead.
+
## Still open
-- **Cost of goods is an average.** It uses each product's average buying
- price across the business, not first-in-first-out per branch. Units sold
- with no purchase on record are counted and flagged, but have no cost.
- **M-Pesa, if turned on.** Only one product per payment. A prompt that
times out still needs a manual check before taking cash.
- **Not modelled yet:** email verification at signup, sending invites by
diff --git a/src/app/admin/reports/page.tsx b/src/app/admin/reports/page.tsx
index cdb82a7..9d5f229 100644
--- a/src/app/admin/reports/page.tsx
+++ b/src/app/admin/reports/page.tsx
@@ -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() {
- {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`}
@@ -272,11 +274,11 @@ export default function ReportsPage() {
>
{money(report.netProfit)}
-
+
Stock purchased
{money(report.stockPurchases)}
@@ -290,6 +292,15 @@ export default function ReportsPage() {
{money(report.cashSurplus)}
Sales − stock purchased − expenses
+
+
Stock losses
+
0 ? "text-alert-600" : "text-depth-900")}>
+ {money(report.stockLosses)}
+
+
+ Cost of stock written off by counts, breakage or adjustments. Deducted in net profit.
+
+
diff --git a/src/app/api/reports/profit/route.ts b/src/app/api/reports/profit/route.ts
index 4a488ae..34953c0 100644
--- a/src/app/api/reports/profit/route.ts
+++ b/src/app/api/reports/profit/route.ts
@@ -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,
diff --git a/src/lib/server/costing.ts b/src/lib/server/costing.ts
new file mode 100644
index 0000000..4ff9d97
--- /dev/null
+++ b/src/lib/server/costing.ts
@@ -0,0 +1,194 @@
+import mongoose, { Types } from "mongoose";
+import { StockMovement, type MovementType } from "@/lib/server/models/StockMovement";
+import { Purchase } from "@/lib/server/models/Purchase";
+
+/**
+ * Cost of goods sold, first-in-first-out, per branch.
+ *
+ * The stock ledger is replayed in order. Each branch keeps, per product, a
+ * queue of cost layers (quantity at a unit cost). Stock coming in adds a
+ * layer; stock going out takes from the oldest layers first:
+ *
+ * PURCHASE in, at the purchase's unit cost
+ * TRANSFER_IN in, at the cost of the layers the sending branch gave up
+ * ADJUSTMENT + in (opening stock, a count that found more), at an
+ * estimated cost: the latest purchase price for the product
+ * at that time, or the first one after it if none yet
+ * SALE out: its cost is cost of goods sold
+ * TRANSFER_OUT out: its cost travels with the stock to the other branch
+ * ADJUSTMENT - out (breakage, a count that found less): its cost is a
+ * stock loss, reported separately from cost of goods
+ *
+ * Stock can go negative (an offline sale recorded before the stock it came
+ * from). The units sold short wait as a deficit and are costed by the next
+ * stock that arrives at that branch, which is where they must have come
+ * from. Units still uncosted at the end are reported as unitsWithoutCost.
+ *
+ * The replay reads the whole history, in order, so a sale is costed against
+ * what was actually on hand when it happened, whatever period is being
+ * reported; only events inside the period (and branch) are added up. A sale
+ * made short in the period is costed by stock that arrived later, even
+ * after the period ended.
+ */
+
+type Id = Types.ObjectId | string;
+
+interface Layer {
+ qty: number;
+ unitCost: number | null; // null: cost unknown
+}
+
+interface Deficit {
+ qty: number;
+ kind: "sale" | "loss" | "transfer";
+ counts: boolean; // inside the reported period and branch
+}
+
+interface Position {
+ layers: Layer[];
+ deficits: Deficit[];
+}
+
+export interface CostOfGoods {
+ costOfGoodsSold: number;
+ unitsCosted: number; // units sold in the period that have a cost
+ unitsWithoutCost: number; // units sold in the period whose cost is unknown
+ stockLosses: number; // cost of stock written off by counts or adjustments in the period
+}
+
+// Process transfers' outgoing side before the incoming side, so the cost is known.
+const TYPE_ORDER: Record = {
+ PURCHASE: 0,
+ ADJUSTMENT: 1,
+ TRANSFER_OUT: 2,
+ TRANSFER_IN: 3,
+ SALE: 4,
+};
+
+const round2 = (n: number) => Math.round(n * 100) / 100;
+
+/** Take `qty` units from the front of `layers`; returns what was taken and the shortfall. */
+function take(layers: Layer[], qty: number): { taken: Layer[]; short: number } {
+ const taken: Layer[] = [];
+ let need = qty;
+ while (need > 0 && layers.length > 0) {
+ const head = layers[0];
+ const n = Math.min(head.qty, need);
+ taken.push({ qty: n, unitCost: head.unitCost });
+ head.qty -= n;
+ need -= n;
+ if (head.qty === 0) layers.shift();
+ }
+ return { taken, short: need };
+}
+
+export async function costOfGoodsSold(
+ businessId: Id,
+ period: { from: Date; to: Date; branchId?: Id | null }
+): Promise {
+ const biz = new mongoose.Types.ObjectId(businessId.toString());
+ const branchFilter = period.branchId ? period.branchId.toString() : null;
+
+ const [movements, purchases] = await Promise.all([
+ StockMovement.find({ businessId: biz })
+ .select("branchId productId type quantity refId createdAt")
+ .sort({ createdAt: 1, _id: 1 })
+ .lean(),
+ Purchase.find({ businessId: biz, productId: { $ne: null } })
+ .select("productId unitCost createdAt")
+ .sort({ createdAt: 1 })
+ .lean(),
+ ]);
+ movements.sort(
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || TYPE_ORDER[a.type] - TYPE_ORDER[b.type]
+ );
+
+ const purchaseCost = new Map(purchases.map((p) => [p._id.toString(), p.unitCost]));
+ const priceHistory = new Map();
+ for (const p of purchases) {
+ const key = p.productId!.toString();
+ if (!priceHistory.has(key)) priceHistory.set(key, []);
+ priceHistory.get(key)!.push({ at: p.createdAt.getTime(), unitCost: p.unitCost });
+ }
+ const estimateCost = (productId: string, at: number): number | null => {
+ const history = priceHistory.get(productId);
+ if (!history || history.length === 0) return null;
+ let latest: number | null = null;
+ for (const h of history) {
+ if (h.at <= at) latest = h.unitCost;
+ else break;
+ }
+ return latest ?? history[0].unitCost;
+ };
+
+ const positions = new Map();
+ const position = (branch: string, product: string) => {
+ const key = `${branch}:${product}`;
+ if (!positions.has(key)) positions.set(key, { layers: [], deficits: [] });
+ return positions.get(key)!;
+ };
+ const inTransit = new Map(); // transfer refId -> layers given up by the sender
+
+ const result = { costOfGoodsSold: 0, unitsCosted: 0, unitsWithoutCost: 0, stockLosses: 0 };
+ const charge = (kind: Deficit["kind"], layers: Layer[]) => {
+ for (const l of layers) {
+ if (kind === "sale") {
+ if (l.unitCost === null) result.unitsWithoutCost += l.qty;
+ else {
+ result.costOfGoodsSold += l.qty * l.unitCost;
+ result.unitsCosted += l.qty;
+ }
+ } else if (kind === "loss" && l.unitCost !== null) {
+ result.stockLosses += l.qty * l.unitCost;
+ }
+ }
+ };
+
+ for (const m of movements) {
+ const branch = m.branchId.toString();
+ const product = m.productId.toString();
+ const pos = position(branch, product);
+ const at = m.createdAt.getTime();
+ const counts =
+ at >= period.from.getTime() && at < period.to.getTime() && (!branchFilter || branch === branchFilter);
+
+ if (m.quantity > 0) {
+ let incoming: Layer[];
+ if (m.type === "PURCHASE") incoming = [{ qty: m.quantity, unitCost: purchaseCost.get(m.refId) ?? null }];
+ else if (m.type === "TRANSFER_IN") incoming = (inTransit.get(m.refId) ?? [{ qty: m.quantity, unitCost: null }]).map((l) => ({ ...l }));
+ else incoming = [{ qty: m.quantity, unitCost: estimateCost(product, at) }];
+
+ // Stock that was already sold short is covered first, at this stock's cost.
+ while (pos.deficits.length > 0 && incoming.length > 0) {
+ const d = pos.deficits[0];
+ const { taken, short } = take(incoming, d.qty);
+ if (d.counts) charge(d.kind, taken);
+ d.qty = short;
+ if (d.qty === 0) pos.deficits.shift();
+ }
+ pos.layers.push(...incoming);
+ } else if (m.quantity < 0) {
+ const kind: Deficit["kind"] = m.type === "SALE" ? "sale" : m.type === "TRANSFER_OUT" ? "transfer" : "loss";
+ const { taken, short } = take(pos.layers, -m.quantity);
+ if (kind === "transfer") {
+ // The receiving branch inherits this cost; any shortfall arrives with no known cost.
+ inTransit.set(m.refId, short > 0 ? [...taken, { qty: short, unitCost: null }] : taken);
+ } else if (counts) {
+ charge(kind, taken);
+ }
+ if (short > 0) pos.deficits.push({ qty: short, kind, counts: counts && kind !== "transfer" });
+ }
+ }
+
+ // Short sales in the period that no later stock has covered yet.
+ for (const pos of positions.values()) {
+ for (const d of pos.deficits) if (d.counts && d.kind === "sale") result.unitsWithoutCost += d.qty;
+ }
+
+ return {
+ costOfGoodsSold: round2(result.costOfGoodsSold),
+ unitsCosted: result.unitsCosted,
+ unitsWithoutCost: result.unitsWithoutCost,
+ stockLosses: round2(result.stockLosses),
+ };
+}
diff --git a/tests/costing.test.ts b/tests/costing.test.ts
new file mode 100644
index 0000000..0bc7cd6
--- /dev/null
+++ b/tests/costing.test.ts
@@ -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 });
+ });
+});
diff --git a/tests/followups.test.ts b/tests/followups.test.ts
index e841c09..41b3cf6 100644
--- a/tests/followups.test.ts
+++ b/tests/followups.test.ts
@@ -100,12 +100,12 @@ describe("F-03 report period validation", () => {
});
describe("F-07 cost of goods sold", () => {
- it("costs only the units sold, at the average buying price, and reports purchases separately", async () => {
+ it("costs only the units sold, first-in-first-out, and reports purchases separately", async () => {
const t = await makeTenant();
const buy = (quantity: number, unitCost: number) =>
purchases.POST(req("POST", "/x", { as: t.managerA, body: { supplierName: "Kiwanja", productId: t.water20, quantity, unitCost } }));
await buy(20, 10);
- await buy(10, 13); // average (200 + 130) / 30 = 11
+ await buy(10, 13);
await cash.POST(
req("POST", "/x", {
as: t.cashierA,
@@ -116,9 +116,9 @@ describe("F-07 cost of goods sold", () => {
expect(body).toMatchObject({
revenue: 660, // 3 x 200 + 1 x 60
unitsSold: 4,
- costOfGoodsSold: 33, // 3 x 11; the 5L was never purchased
+ costOfGoodsSold: 30, // 3 x 10 from the oldest purchase; the 5L was never purchased
unitsWithoutCost: 1,
- grossProfit: 627,
+ grossProfit: 630,
stockPurchases: 330,
cashSurplus: 330,
});