Cost sales first-in-first-out per branch instead of at an average #7
+21
-4
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-xs uppercase tracking-wide text-depth-500">Cost of goods sold</p>
|
||||
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.costOfGoodsSold)}</p>
|
||||
<p className="mt-1 text-xs text-depth-500">
|
||||
{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`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -272,11 +274,11 @@ export default function ReportsPage() {
|
||||
>
|
||||
{money(report.netProfit)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses</p>
|
||||
<p className="mt-1 text-xs text-depth-500">Gross profit − expenses − stock losses</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||
<p className="text-xs uppercase tracking-wide text-depth-500">Stock purchased</p>
|
||||
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.stockPurchases)}</p>
|
||||
@@ -290,6 +292,15 @@ export default function ReportsPage() {
|
||||
<p className="mt-1 font-display text-2xl font-bold text-depth-900">{money(report.cashSurplus)}</p>
|
||||
<p className="mt-1 text-xs text-depth-500">Sales − stock purchased − expenses</p>
|
||||
</div>
|
||||
<div className="rounded-xl2 bg-white p-5 shadow-soft">
|
||||
<p className="text-xs uppercase tracking-wide text-depth-500">Stock losses</p>
|
||||
<p className={"mt-1 font-display text-2xl font-bold " + (report.stockLosses > 0 ? "text-alert-600" : "text-depth-900")}>
|
||||
{money(report.stockLosses)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-depth-500">
|
||||
Cost of stock written off by counts, breakage or adjustments. Deducted in net profit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl2 border border-depth-200 bg-sand-100 px-4 py-3">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<MovementType, number> = {
|
||||
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<CostOfGoods> {
|
||||
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<string, { at: number; unitCost: number }[]>();
|
||||
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<string, Position>();
|
||||
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<string, Layer[]>(); // 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),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user