A café's margin lives in very small numbers: 18 grams of beans, 200 millilitres of milk, a few cents of packaging. Get those wrong and the P&L is fiction, however nice the charts look. When we built CafePro, our multi-tenant restaurant and café SaaS on Next.js 15 and MongoDB, we wanted owners to see food cost and gross margin for any day without waiting for a stock count. This article covers how recipes are costed, why every order line carries a cost snapshot, and why the P&L offers two COGS bases plus the gap between them.
Minor units everywhere, fractions only where they belong
Every stored amount in CafePro is an integer in the currency's minor unit. Each business picks its own currency, so the exponent comes from Intl (USD 2, JPY 0, KWD 3) rather than a hard-coded 100:
/** 12.34 USD → 1234 */
export const toMinor = (major: number, currency = 'USD'): number => Math.round((Number.isFinite(major) ? major : 0) * 10 ** currencyDecimals(currency));
There is one deliberate exception. Ingredients are bought by the pack ("$4.80 for a 2 l bottle") but used by the base unit, so the ingredient model keeps a fractional cost per gram or millilitre:
/** Minor units per base unit (fractional), derived from pack cost / size. */
costPerUnit: number;
Rounding at that level would destroy the information. Milk at $2 for 2 litres is 0.1¢ per millilitre; round that to zero and every flat white looks free. We round once, at the portion, where a whole minor unit means something.
A recipe is a batch, not a portion
Recipes describe how kitchens work: a batch of ingredients that makes yieldQty portions, with a wastePct allowance for trim and spillage. The cost per portion is a pure function:
/** Cost per portion in minor units: Σ qty × cost per unit, plus waste, divided by yield. */
export function recipeUnitCost(r: Pick<IRecipe, 'lines' | 'yieldQty' | 'wastePct'>, costs: Map<string, number>): number {
const batch = r.lines.reduce((s, l) => s + l.qty * (costs.get(String(l.ingredientId)) ?? 0), 0);
return Math.round((batch * (1 + r.wastePct / 100)) / Math.max(1, r.yieldQty));
}
The integration test uses a latte, with beans at $20 per kilogram and milk at $2 per 2 litres: 18 g × 2¢ + 200 ml × 0.1¢ = 36 + 20 = 56¢. The fractional costs are summed exactly, and only the portion cost is rounded.
Price changes propagate; history does not move
When a supplier raises the price of beans, every recipe that uses beans must be recosted, and every menu item linked to those recipes must pick up the new cost. saveIngredient triggers this through recostRecipes, backed by an index on lines.ingredientId:
const recipes = await Recipe.find(q).lean();
if (!recipes.length) return 0;
const costs = await costMap(tenantId, recipes.flatMap((r) => r.lines.map((l) => l.ingredientId)));
for (const r of recipes) {
const unitCost = recipeUnitCost(r, costs);
await Recipe.updateOne({ tenantId, _id: r._id }, { $set: { unitCost } });
await MenuItem.updateMany({ tenantId, recipeId: r._id }, { $set: { unitCost } });
}
The same test then raises beans to $30 per kilogram and checks that the latte moves from 56¢ to 74¢.
Recosting the menu must not rewrite the past, though. If last month's lattes suddenly cost 74¢, last month's margin would change after the fact. So the cost is copied onto each order line at the moment of sale, next to the price:
/** Price and cost are snapshotted per line so reports never move when the menu changes. */
export type OrderLine = { menuItemId: Types.ObjectId | null; categoryId: Types.ObjectId | null; name: string; qty: number; unitPrice: number; unitCost: number; note: string };
Invoices for catering and events do the same: InvoiceLine.unitCost is the recipe cost snapshot for menu-item lines and zero for services. Both documents also store a precomputed cogs total, so the P&L sums one field instead of unwinding lines. Items without a recipe, such as a bought-in muffin, take a manually entered cost. If a recipe is deleted, linked items keep their last cost and are unlinked, so tidying up the recipe book doesn't zero out the margin report.
The server rebuilds every ticket
A snapshot is only as trustworthy as the code that writes it. The POS client sends menu item ids and quantities, never prices or costs, and the server rebuilds the ticket:
/** Rebuilds the ticket from the database: prices, costs and totals never come from the client. */
async function build(ctx: TenantCtx, input: OrderInput) {
// ...location, discount limit and table checks
return { menuItemId: it._id, categoryId: it.categoryId, name: it.name, qty: l.qty, unitPrice: it.price, unitCost: it.unitCost, note: l.note };
// ...
const cogs = lines.reduce((sum, l) => sum + Math.round(l.unitCost * l.qty), 0);
build() runs when a ticket is held and again when it is charged, so a ticket parked on a table is priced and costed with the menu as it stands at payment. For a café, that is when the sale happens. The order's date, a YYYY-MM-DD string in the business's time zone, is also set at payment, so a ticket opened before midnight and paid after it counts on the day it was paid.
Two COGS bases and the variance between them
There are two honest answers to "what did our food cost this month?":
- Recipe COGS is the theoretical cost of what was sold: the sum of the snapshots on paid orders and issued invoices. It is available in real time, but it only knows what the recipes say.
- Purchases COGS is what was actually spent on stock: approved or paid expenses in the
Ingredients,BeveragesandPackagingcategories, net of tax. It reflects reality, waste included, but it is lumpy, because a café may buy a month of cups in one order.
profitAndLoss runs three aggregations in parallel (orders, invoices, expenses), computes both, and lets the caller pick the basis:
const recipeCogs = p.cogs + i.cogs;
const purchaseLines = exp.filter((e) => COGS_CATEGORIES.includes(e._id));
const purchases = purchaseLines.reduce((s, e) => s + e.net, 0);
const cogs = basis === 'RECIPE' ? recipeCogs : purchases;
const opexLines = exp.filter((e) => !COGS_CATEGORIES.includes(e._id)).map((e) => ({ category: e._id, amount: e.net }));
Purchase categories never count as operating expenses, so nothing is counted twice on either basis. Whichever basis is selected, the result carries both numbers and variance: purchases - recipeCogs.
The variance is the useful part. A positive variance means more was bought than the recipes say was used: waste, unrecorded comps or staff meals, recipes that understate portions, or stock building up on the shelf. A persistently negative one usually means a recipe overstates a portion or a purchase was miscategorised. The P&L can't say which, but it says where to look.
Revenue is net of sales tax: total − tax for paid orders, and for invoices in any status except DRAFT and VOID. The integration test ties this out on one day of data: two POS orders, an issued invoice, a draft that must not count, a beverage purchase, a utilities bill with tax, and a pending expense that must not count. It asserts revenue, both COGS figures, the variance and net profit on each basis. The response also carries foodCostPct and a labourPct taken from the Payroll expense category. Percentages are rounded to one decimal for display, and every amount stays an integer.
Trade-offs and what we're cautious about
- Recosting is not transactional.
recostRecipesupdates recipes and menu items in sequence. A crash part-way through leaves some items on the old cost until the ingredient or recipe is saved again. We accepted this because the operation is idempotent and past orders keep their snapshots. - There is no stock count. CafePro tracks ingredient prices, not quantities on hand, so the variance can't separate waste from stock bought and not yet used. Over a long period these even out; over a week they don't. The reports page therefore says the gap "points to waste, over-portioning, comps or stock build-up" rather than calling it waste.
- Shared expenses appear on every outlet. When the P&L is filtered to one location, expenses with no location (rent paid centrally, say) are included. That overstates each outlet's costs, but dropping them would make every outlet look more profitable than the business is. Allocating shared costs across outlets isn't built yet.
- No income tax or interest. Net profit is operating profit before tax, and the code says so in a comment, not only in the docs.
Conclusion
Most of this design is about deciding when a number is fixed. Ingredient costs stay fractional until a portion is costed. A portion's cost flows to the menu when prices change and is frozen on the order line when the sale happens. Two COGS bases then turn the gap between theory and reality into a signal instead of an error. The CafePro case study covers the wider product, the live demo is read-only with one-click demo logins, and the source is on our marketplace. If you need this kind of costing in your own product, our SaaS engineering team can help.



