Files
ERP/tests/followups.test.ts
denis_ireri ff75cf2fa7 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>
2026-10-12 12:08:15 +03:00

242 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import mongoose from "mongoose";
import { Business } from "@/lib/server/models/Business";
import { Branch } from "@/lib/server/models/Branch";
import { User } from "@/lib/server/models/User";
import { Product } from "@/lib/server/models/Product";
import { Purchase } from "@/lib/server/models/Purchase";
import { backfillPurchaseMovements, stockOf, getStockLevels } from "@/lib/server/stock";
import * as branches from "@/app/api/branches/route";
import * as branchById from "@/app/api/branches/[id]/route";
import * as users from "@/app/api/users/route";
import * as invites from "@/app/api/invites/[token]/route";
import * as reinvite from "@/app/api/users/[id]/invite/route";
import * as login from "@/app/api/auth/login/route";
import * as products from "@/app/api/products/route";
import * as productById from "@/app/api/products/[id]/route";
import * as purchases from "@/app/api/purchases/route";
import * as profit from "@/app/api/reports/profit/route";
import * as cash from "@/app/api/transactions/cash/route";
import * as adjustments from "@/app/api/stock/adjustments/route";
import * as transfers from "@/app/api/stock/transfers/route";
import { json, makeTenant, params, req, uuid, type Tenant, db } from "./helpers";
async function setPlan(t: Tenant, tier: string, subscriptionStatus: string) {
await Business.updateOne({ _id: t.businessId }, { tier, subscriptionStatus });
}
const newBranch = (name: string) => ({ branchName: name, locationCity: "Nakuru" });
describe("F-01 trial branch allowance", () => {
it("lets a trialling Starter business run two branches, not three", async () => {
const t = await makeTenant(); // already has branches A and B
await setPlan(t, "STARTER", "TRIALING");
const third = await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }));
expect(third.status).toBe(403);
expect((await third.json()).error).toMatch(/free trial allows 2/);
});
it("counts only active branches, and a paid Starter plan allows one", async () => {
const t = await makeTenant();
await setPlan(t, "STARTER", "TRIALING");
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("C") }))).status).toBe(201);
await setPlan(t, "STARTER", "ACTIVE");
expect((await branches.POST(req("POST", "/x", { as: t.owner, body: newBranch("D") }))).status).toBe(403);
});
});
describe("F-06 branch deactivation", () => {
it("refuses while the branch still holds stock or active staff, then succeeds once emptied", async () => {
const t = await makeTenant();
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchB, productId: t.water20, mode: "DELTA", quantity: 4 } })
);
const deactivate = () =>
branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: false } }), params({ id: t.branchB }));
const withStock = await deactivate();
expect(withStock.status).toBe(409);
expect((await withStock.json()).error).toMatch(/4 x 20L Jerrycan/);
await transfers.POST(
req("POST", "/x", {
as: t.owner,
body: { clientUuid: uuid(), fromBranchId: t.branchB, toBranchId: t.branchA, productId: t.water20, quantity: 4 },
})
);
const withStaff = await deactivate();
expect(withStaff.status).toBe(409);
expect((await withStaff.json()).error).toMatch(/active staff/);
await db(() => User.updateOne({ _id: t.cashierB.id }, { isActive: false }));
expect((await deactivate()).status).toBe(200);
// Hidden from pickers, kept on the Branches page.
const active = await json(await branches.GET(req("GET", "/api/branches", { as: t.owner })));
const all = await json(await branches.GET(req("GET", "/api/branches?includeInactive=1", { as: t.owner })));
expect(active.body.branches.map((b: any) => b._id)).toEqual([t.branchA]);
expect(all.body.branches).toHaveLength(2);
});
it("will not reactivate a branch past the plan's limit", async () => {
const t = await makeTenant();
await db(() => Branch.updateOne({ _id: t.branchB }, { isActive: false }));
await setPlan(t, "STARTER", "ACTIVE");
const res = await branchById.PATCH(req("PATCH", "/x", { as: t.owner, body: { isActive: true } }), params({ id: t.branchB }));
expect(res.status).toBe(403);
});
});
describe("F-03 report period validation", () => {
it("rejects a reversed period instead of showing an empty report", async () => {
const t = await makeTenant();
const res = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-12&toDate=2026-10-11", { as: t.owner }));
expect(res.status).toBe(400);
const sameDay = await profit.GET(req("GET", "/api/reports/profit?fromDate=2026-10-11&toDate=2026-10-11", { as: t.owner }));
expect(sameDay.status).toBe(200);
});
});
describe("F-07 cost of goods sold", () => {
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);
await cash.POST(
req("POST", "/x", {
as: t.cashierA,
body: { sales: [{ clientUuid: uuid(), lines: [{ productId: t.water20, quantity: 3 }, { productId: t.water5, quantity: 1 }] }] },
})
);
const { body } = await json(await profit.GET(req("GET", "/api/reports/profit", { as: t.owner })));
expect(body).toMatchObject({
revenue: 660, // 3 x 200 + 1 x 60
unitsSold: 4,
costOfGoodsSold: 30, // 3 x 10 from the oldest purchase; the 5L was never purchased
unitsWithoutCost: 1,
grossProfit: 630,
stockPurchases: 330,
cashSurplus: 330,
});
});
});
describe("purchase-to-stock backfill", () => {
it("turns pre-ledger purchases into stock, once", async () => {
const t = await makeTenant();
await Purchase.create({
businessId: t.businessId,
branchId: t.branchA,
supplierName: "Old supplier",
productId: t.water20,
productLabel: "20L Jerrycan",
quantity: 20,
unitCost: 10,
totalCost: 200,
recordedBy: t.owner.id,
});
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(0);
expect(await backfillPurchaseMovements()).toBe(1);
expect(await backfillPurchaseMovements()).toBe(0);
expect(await stockOf(t.businessId, t.branchA, t.water20)).toBe(20);
});
});
describe("F-04 / F-05 / CAT-12 catalogue", () => {
it("lists deactivated products for managers and keeps their stock visible", async () => {
const t = await makeTenant();
await adjustments.POST(
req("POST", "/x", { as: t.owner, body: { clientUuid: uuid(), branchId: t.branchA, productId: t.water5, mode: "DELTA", quantity: 7 } })
);
await db(() => Product.updateOne({ _id: t.water5 }, { isActive: false }));
const forManager = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.managerA })));
const forCashier = await json(await products.GET(req("GET", "/api/products?includeInactive=1", { as: t.cashierA })));
expect(forManager.body.products).toHaveLength(2);
expect(forCashier.body.products).toHaveLength(1);
const levels = await getStockLevels(t.businessId);
expect(levels.find((l) => l.productId === t.water5 && l.branchId === t.branchA)).toMatchObject({
quantity: 7,
productActive: false,
});
expect(levels.some((l) => l.productId === t.water5 && l.branchId === t.branchB)).toBe(false);
});
it("stores count units, refuses duplicate names and zero prices", async () => {
const t = await makeTenant();
const create = (body: object) => products.POST(req("POST", "/x", { as: t.owner, body }));
const tray = await json(await create({ label: "Tray of Eggs", sizeLiters: 30, unit: "pcs", unitPrice: 400 }));
expect(tray.status).toBe(201);
expect(tray.body.product.unit).toBe("pcs");
expect((await create({ label: "tray of eggs", sizeLiters: 1, unit: "tray", unitPrice: 400 })).status).toBe(409);
expect((await create({ label: "Free sample", sizeLiters: 1, unit: "pcs", unitPrice: 0 })).status).toBe(400);
expect((await create({ label: "Bucket", sizeLiters: 1, unit: "buckets", unitPrice: 50 })).status).toBe(400);
const rename = await productById.PATCH(
req("PATCH", "/x", { as: t.owner, body: { label: "20L JERRYCAN" } }),
params({ id: t.water5 })
);
expect(rename.status).toBe(409);
});
});
describe("STA-04 staff invites", () => {
async function invite(t: Tenant) {
const res = await json(
await users.POST(
req("POST", "/api/users", {
as: t.managerA,
body: { name: "New Cashier", role: "DELIVERY", phoneNumber: "0711222333", email: "new.cashier@aquaflow.test" },
})
)
);
expect(res.status).toBe(201);
return { userId: res.body.user._id as string, token: (res.body.inviteUrl as string).split("/invite/")[1] };
}
const signIn = (password: string) =>
login.POST(req("POST", "/x", { body: { email: "new.cashier@aquaflow.test", password, deviceId: uuid() } }));
it("lets the invited person set their own password, once", async () => {
const t = await makeTenant();
const { token } = await invite(t);
const who = await json(await invites.GET(req("GET", "/x"), params({ token })));
expect(who.body).toMatchObject({ name: "New Cashier", email: "new.cashier@aquaflow.test" });
expect((await invites.POST(req("POST", "/x", { body: { password: "short" } }), params({ token }))).status).toBe(400);
expect((await invites.POST(req("POST", "/x", { body: { password: "my-own-secret" } }), params({ token }))).status).toBe(200);
expect((await signIn("my-own-secret")).status).toBe(200);
// The link is single-use.
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
});
it("issues a fresh link that replaces the old one, and rejects expired links", async () => {
const t = await makeTenant();
const { userId, token } = await invite(t);
const again = await json(await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: userId })));
expect(again.status).toBe(200);
const fresh = (again.body.inviteUrl as string).split("/invite/")[1];
expect((await invites.GET(req("GET", "/x"), params({ token }))).status).toBe(404);
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(200);
await db(() => User.updateOne({ _id: new mongoose.Types.ObjectId(userId) }, { inviteExpiresAt: new Date(Date.now() - 1000) }));
expect((await invites.GET(req("GET", "/x"), params({ token: fresh }))).status).toBe(404);
});
it("does not let a manager re-invite another branch's staff", async () => {
const t = await makeTenant();
await db(() => User.updateOne({ _id: t.cashierB.id }, { inviteExpiresAt: new Date(Date.now() + 60_000) }));
const res = await reinvite.POST(req("POST", "/x", { as: t.managerA }), params({ id: t.cashierB.id }));
expect(res.status).toBe(403);
});
});