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