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