From ff75cf2fa76717a4dfdd44423d6eca65e29a0b17 Mon Sep 17 00:00:00 2001 From: denisireri Date: Mon, 12 Oct 2026 12:08:15 +0300 Subject: [PATCH] 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 --- docs/PILOT_READINESS.md | 25 +++- src/app/admin/reports/page.tsx | 19 ++- src/app/api/reports/profit/route.ts | 37 +++--- src/lib/server/costing.ts | 194 ++++++++++++++++++++++++++++ tests/costing.test.ts | 110 ++++++++++++++++ tests/followups.test.ts | 8 +- 6 files changed, 362 insertions(+), 31 deletions(-) create mode 100644 src/lib/server/costing.ts create mode 100644 tests/costing.test.ts 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() {

Cost of goods sold

{money(report.costOfGoodsSold)}

- {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)}

-

Gross profit − expenses

+

Gross profit − expenses − stock losses

-
+

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, }); -- 2.52.0