HomeMethodology
Methodology

How EYP Ops calculates food cost

A plain-language reference for the math, the data model and the engineering decisions that produce every COGS number, variance line and closing balance you see in the product.

Calculation methodology

EYP Ops food cost methodology is the public reference for how EYP Ops computes actual food cost, theoretical food cost, variance, FIFO/WAC valuation, immutable stock movements and period-close snapshots in restaurant operations.

This page documents how EYP Ops actually computes food cost — not the marketing version, the engineering one. It exists because most operators have been burned at least once by a system whose numbers shifted overnight, whose closing balance disagreed with the count sheet, or whose "cost of goods sold" quietly recalculated when a supplier price changed. We want you to be able to read this, audit your own data, and understand exactly which row in which table produced the number on your dashboard.

The model has eight moving parts. We will walk through each one in order: the ledger, theoretical vs actual food cost, FIFO and weighted average cost, immutable historical cost, period close mechanics, the full list of stock movement types, the difference between outlet-level and per-location accounting, and finally how variance is reviewed and approved during a stock count. Worked examples use AED, the operating currency for Dubai-based clients.

1. What is ledger-based inventory?

In EYP Ops, stock is not a number that gets overwritten — it is a sum over a list of events. Every change to stock, anywhere in the system, is recorded as a row in a single table called StockMove. Each row carries a quantity (signed: positive for inbound, negative for outbound), a unit cost, an item, a location, an effective timestamp, a movement type, and a reference to the source document that authorised it. Once written, the row is never updated. It is never deleted. Corrections happen by adding new rows, not by editing old ones.

We call this an append-only ledger. The same idea sits underneath every double-entry accounting system in the world, and underneath every blockchain, and for the same reason: an append-only log is the only data structure where you can prove that no one rewrote history. If your stock figure on Monday disagrees with your stock figure on Friday, you can replay the moves between Monday and Friday and see exactly which document caused which change.

Every stock movement is also bound to a source document. A purchase invoice produces INVOICE_IN rows. A point-of-sale ticket triggers RECIPE_OUT rows. A wastage record produces WASTE_OUT rows. A transfer between two locations produces a paired TRANSFER_OUT at the origin and a TRANSFER_IN at the destination. There is no "manually adjust stock" button anywhere in EYP Ops, and there cannot be — every move must point at a document the operator can open, read, and challenge.

This means the on-hand quantity for any item, at any location, at any moment in history, is fully reconstructable. The formula is straightforward:

on_hand(item, location, t) =
  SUM(StockMove.qty)
  WHERE item_id = item
    AND location_id = location
    AND effective_at <= t
    AND is_reversal = false

That single query — and small variations on it — is the foundation of every report in the product. The closing balance on a stock-on-hand screen is this query at now. The opening balance for a period is this query at period_start. The historical view of stock at any past timestamp is this query at that timestamp. There is no caching trick that lies to you, no "current stock" field that can drift out of sync with the moves table, because there is no other source of truth.

Counter-example. The traditional approach — sometimes called "perpetual inventory" in textbooks, though many software vendors implement it carelessly — keeps a single stock_on_hand field per item per location, and updates it in place every time anything happens. A purchase increments it. A sale decrements it. A correction overwrites it. This is faster to query (one row, one read) but catastrophic for audit, because once a value has been overwritten there is no record of what it was before. If today's closing stock is 800 g and yesterday's was 1000 g, you can see the delta but you cannot prove what caused it. A bug, a buggy import, or a malicious user can silently rewrite the past.

Worked example. Suppose you receive 1 kg of LAMB SHOULDER on Monday at AED 60/kg, and on Tuesday a guest orders a dish whose recipe consumes 200 g of LAMB SHOULDER. The ledger contains two rows:

# Monday 14:00, INVOICE_IN, qty +1000 g, unit_cost AED 0.060/g
# Tuesday 19:30, RECIPE_OUT, qty -200 g, unit_cost AED 0.060/g
# on_hand at any time after Tuesday 19:30 = 1000 - 200 = 800 g
# COGS for Tuesday = 200 g × AED 0.060 = AED 12.00

On a stock-on-hand screen you see one item with 800 g remaining. In the audit trail you see two events. Both views are derived from the same two rows. They cannot disagree.

2. Food cost: theoretical vs actual

Food cost in a restaurant has two distinct measurements, and the difference between them is where most operational profit hides. We compute both, and we report the gap explicitly.

Theoretical food cost

Theoretical food cost is what your sales should have consumed, based on what was sold and what each recipe says it uses. The formula is:

theoretical_cost(period) =
  Σ over POS lines in period:
      pos_quantity × Σ over recipe components:
        component_quantity × component_unit_cost

In the ledger, this is the sum of all RECIPE_OUT rows in the period multiplied by their unit cost. Because every POS sale produces RECIPE_OUT rows tied to that sale's recipes, theoretical cost is not estimated — it is the literal cost of the depletions the recipes wrote.

Actual food cost

Actual food cost is what your kitchen really consumed during the period, regardless of what the recipes say. The classic accounting formula is:

actual_cost(period) =
  opening_value
  + purchases_value
  - supplier_returns_value
  - closing_value

All four values come from the ledger. Opening and closing are the on-hand query above, valued at unit cost, evaluated at the period boundaries. Purchases and supplier returns are sums of the corresponding movement types within the period.

Variance

Variance is the difference between the two:

variance = actual_cost - theoretical_cost

A positive variance means you used more than your recipes expected. The candidates are: a recipe is missing or under-quantified (so theoretical is artificially low), portion drift in the kitchen, waste that was never logged, internal consumption (staff meals not recorded), miscounted opening or closing, or theft. A negative variance — using less than recipes expect — usually means a recipe is over-quantified, a sale was rung up against the wrong menu item, or staff meals are being double-counted somewhere.

Worked example. Assume a single-item lunch service. The kitchen sells 100 burgers in a week. The burger recipe lists 200 g of LAMB SHOULDER per burger, costed at AED 30/kg. Theoretical consumption is 100 × 200 g = 20 kg, costed at AED 600. The opening count was 25 kg and the closing count is 3 kg, with one purchase of 0 kg in the week (none received). Actual consumption is 25 + 0 − 3 = 22 kg, costed at AED 660. The variance is AED 60, which corresponds to 2 kg of unaccounted lamb. EYP Ops surfaces this number, with the breakdown by item, on the variance report — and the next stock count gives the manager an opportunity to attach a reason code (waste, theft, portion drift, recipe gap) so the same variance does not silently repeat.

3. Cost methods: FIFO vs Weighted Average

The unit cost stamped on every outbound stock movement depends on the cost method assigned to that item. EYP Ops supports two: First In First Out (FIFO) and Weighted Average Cost (WAC). The choice is per item, not per company — different items behave differently and the model accommodates that explicitly.

FIFO — First In, First Out

Under FIFO, every inbound purchase is treated as a separate lot with its own unit cost. When stock is consumed, the system depletes lots in the order they arrived: the oldest lot is exhausted first, then the next-oldest, and so on. The unit cost of each outbound movement is the unit cost of the lot it drew from. If a single consumption spans two lots, the system splits the movement into two rows, each with the correct lot cost.

FIFO is the right model for items where physical rotation actually follows arrival order — fresh produce, herbs, dairy, anything with a short shelf life. The inventory valuation under FIFO leans toward the most recent purchase price, which is closer to replacement cost on the balance sheet.

WAC — Weighted Average Cost

Under WAC, every inbound purchase recomputes a single rolling unit cost for the item, weighted by quantities. The formula after each receipt is:

new_unit_cost =
  (existing_qty × existing_unit_cost
   + received_qty × received_unit_cost)
  / (existing_qty + received_qty)

Outbound movements are stamped with the rolling WAC at the moment they post. WAC is the right model for fungible, long-shelf-life ingredients where physical rotation does not meaningfully follow arrival order — flour, sugar, bulk oil, dry goods. It also produces a smoother COGS line, which some operators prefer for trend analysis.

Worked example. Receive 100 kg of flour at AED 20/kg on Monday, then 100 kg of flour at AED 24/kg on Wednesday. On Thursday a recipe consumes 150 kg.

# Under WAC:
#   after Monday   : 100 kg @ AED 20.00/kg
#   after Wednesday: 200 kg @ AED 22.00/kg  (rolling avg)
#   Thursday OUT 150 kg @ AED 22.00/kg = AED 3,300.00
#   closing 50 kg @ AED 22.00/kg = AED 1,100.00
#
# Under FIFO:
#   after Monday   : Lot A — 100 kg @ AED 20/kg
#   after Wednesday: Lot A 100 kg, Lot B 100 kg @ AED 24/kg
#   Thursday OUT splits: 100 kg @ 20 + 50 kg @ 24
#                       = AED 2,000 + AED 1,200 = AED 3,200
#   closing 50 kg of Lot B @ AED 24/kg = AED 1,200

Both methods are mathematically valid. The right choice for any given item is whichever model better reflects how that item physically moves through your kitchen. Items default to WAC because it is the safer model for the largest number of categories, but you can change the cost method per item in settings.

4. Mark-to-market vs immutable cost

The unit cost on a stock movement is written at the moment the movement is posted, and from that point on it never changes. Not when a new purchase arrives at a different price. Not when the rolling WAC for the item shifts. Not when a report is run. The historical cost of a historical depletion is fixed forever.

This is what we mean by an immutable cost ledger, and it is one of the most important architectural decisions in the product. The alternative — sometimes called "mark-to-market" costing — recomputes the unit cost of historical movements every time a report is generated, using whatever the latest price is. Mark-to-market sounds reasonable until you actually run it: yesterday's closing balance can change overnight because a new purchase arrived this morning at a different price; last month's COGS can shift after the books are closed; two reports run on the same data five minutes apart can disagree.

We have seen this class of bug in production systems and it is genuinely hard to debug, because the data on disk is fine — only the report logic drifts. The decision in EYP Ops is therefore architectural rather than procedural: the unit cost field on the StockMove row is the source of truth, and no report path is allowed to recompute it. RECIPE_OUT, WASTE_OUT, STAFF_MEAL_OUT, PRODUCTION_OUT, and butcher cuts all persist their post-time cost. If you ever need a historical view at a different valuation, that requires an explicit, opt-in CostSnapshot model — it cannot happen by accident from a stale code path.

An industry note. A well-known F&B inventory product, Orion, ran with a mark-to-market cost recomputation behaviour through versions in the 4.1 series, and resolved the symptom in a later 6.x release per their public release notes. We are not citing this to score points; we are citing it because the pattern is common enough that operators should know to ask their software vendor explicitly: does the unit cost on a posted stock movement ever change after posting? If the answer is yes, COGS and variance reports are quietly being recalculated on every purchase, and historical numbers are not really historical.

Worked example. Yesterday you received 100 kg of LAMB SHOULDER at AED 60/kg and the kitchen depleted 30 kg through recipe sales. Each RECIPE_OUT row carries unit cost AED 60/kg; yesterday's COGS for that item is AED 1,800. Today the next purchase arrives at AED 65/kg. In EYP Ops, yesterday's COGS line is still AED 1,800 — the rows you posted yesterday remain unchanged. In a mark-to-market system, yesterday's rows would now read AED 65/kg, yesterday's COGS would silently jump to AED 1,950, and tomorrow's report on yesterday's data would show a different number than today's report did.

If you need to correct a unit cost — and there are legitimate reasons to, like an invoice that posted with the wrong price — the way to do it in EYP Ops is to reverse the original movements and repost them with the corrected cost. The reversal and the repost are both visible in the audit trail, with both their timestamps and the user who triggered each one. Nothing is rewritten in place.

5. Period close mechanics

Most operators close their books monthly, and the inventory side of that close has its own mechanics. EYP Ops models periods explicitly through a StockPeriod record per outlet per month, and a Stock Period Snapshot table that holds the canonical opening and closing values for every (location, item) tuple at the moment of close.

Closing a period

When a period is locked, the system materialises a snapshot row for every item at every location: opening quantity and value (carried over from the previous period's closing snapshot), and closing quantity and value (computed from the ledger as of the period end). After lock, the Period Inventory report reads only from the snapshot table for that period — there is no live ledger fallback. This is deliberate: once a period is closed, the numbers a manager or auditor sees should not change underneath them, even if some late-arriving correction appears in the ledger.

Reopening a period

Reopening a locked period is a destructive operation. It requires a role with explicit unlock permission, it writes an audit log entry naming the user and the reason, and it asserts a chain of period locks: you cannot reopen February if January is unlocked, because that would invalidate the carried-forward openings. The system blocks any reopen that would leave the chain inconsistent with a clear error message rather than silently accepting it.

Count as a reset point, not a movement

Stock counts in EYP Ops are reset points, not stock movements. When a count session is posted, the on-hand calculation at any time after the count uses the counted quantity as its starting point, plus the moves recorded after the count. A regular count does not write rows into the ledger — it writes a CountSession record which the on-hand engine treats as a reference. This matters because if counts wrote ledger rows, every count would create a phantom adjustment that distorted COGS for the period.

The on-hand query becomes:

on_hand_count_aware(item, location, t) =
  if there is a posted count of (item, location)
     with count_time c <= t:
    counted_qty + SUM(StockMove.qty between c and t)
  else:
    SUM(StockMove.qty up to t)   -- raw ledger fallback

The opening count exception

Opening counts are a special case. When you onboard a new location or start a new period from scratch with no prior history, there is no previous snapshot to roll forward from. In that case the opening count writes OPENING_IN rows into the ledger at a timestamp set just before the period start. This bootstraps the system with an authoritative starting balance that downstream reports can rely on. Once the location has at least one closed period, regular counts revert to the reset-only behaviour described above.

6. Movement types and their source documents

Every row in the StockMove ledger carries a movement type that names what kind of business event the row represents, and points at the source document that authorised it. The full list is below. We expose this list in the product because operators occasionally need to filter or audit by type, but day-to-day work happens in the document-level views (invoices, transfers, wastages) — not in the raw ledger.

  • INVOICE_IN — inbound stock from a supplier purchase. Source document: PurchaseInvoice line. Quantity is positive; unit cost is the invoice line cost (after tax-exclusive normalisation, see the audit notes in the product).
  • RECIPE_OUT — outbound stock consumed by a POS sale. Source document: POSSale line, expanded through the Recipe model. Quantity is negative; unit cost is the cost of the depleted lot (FIFO) or the rolling weighted average (WAC) at the moment of the sale.
  • TRANSFER_IN / TRANSFER_OUT — stock moving between locations or outlets. Source document: Transfer record. Always paired — every TRANSFER_OUT at the origin has a matching TRANSFER_IN at the destination, with equal absolute quantity.
  • WASTE_OUT — stock written off as wastage (spoilage, breakage, expiry). Source document: Wastage record, with mandatory reason code.
  • STAFF_MEAL_OUT — stock consumed by staff meals, recorded outside the POS so it does not pollute revenue or theoretical cost. Source document: StaffMeal record.
  • PRODUCTION_IN / PRODUCTION_OUT — sub-recipe and semi-finished goods production. PRODUCTION_OUT depletes the input ingredients; PRODUCTION_IN credits the output good. The two sides are linked by the Production record, and the cost of the produced good is computed from the cost of its inputs.
  • ADJ_IN / ADJ_OUT — variance adjustments written by a posted count session when the counted quantity differs from the system-expected quantity. Source document: CountSession + variance reason code.
  • BUTCHER_IN / BUTCHER_OUT — butcher yield processing, where one input item (e.g. 1 kg of whole chicken) is broken down into multiple output items (e.g. 600 g breast + 400 g other parts). BUTCHER_OUT depletes the input; BUTCHER_IN credits each output. The cost is allocated across outputs based on per-output yield weights configured on the butcher recipe.
  • OPENING_IN / OPENING_ADJ_IN / OPENING_ADJ_OUT — opening count seed and corrections used during initial setup or location onboarding.
  • INTERIM_ADJ_IN / INTERIM_ADJ_OUT — adjustments from a partial mid-period count, used when a manager wants to correct a known drift without waiting for the formal period-close count.
  • SUPPLIER_RETURN_OUT — stock returned to a supplier (damaged, wrong delivery, quality reject). Source document: SupplierReturn record. Reduces actual food cost in the period because the value flows out of inventory but does not appear in COGS.

The product also enforces a movement-type whitelist per location kind through the LocationAllowedMove policy. A storage warehouse cannot receive a RECIPE_OUT (warehouses do not produce sales). A production location cannot receive an INVOICE_IN (suppliers deliver to receiving locations, not production benches). These checks are enforced at the service layer with a hard fail rather than a silent reroute, so an operator who configures something inconsistently sees the error immediately.

7. Multi-outlet and per-location accounting

A single restaurant in EYP Ops typically has multiple internal locations: a main kitchen, a bar, a cold store, a dry store, sometimes a butcher bench or a pastry production area. These are physical sub-locations within one operating outlet. The model has to handle two questions at once: how much stock does the outlet own, and where exactly is it.

Outlet is primary

The primary unit of accounting in EYP Ops is the outlet, not the individual location. Variance, COGS and food-cost percentage are reported at the outlet level, summing across all internal locations. Intra-outlet transfers — for example, moving 5 kg of lamb from the kitchen to the bar — net to zero at the outlet level, even though they create paired TRANSFER_OUT and TRANSFER_IN rows at the location level. This means a busy night where staff move ingredients around internally does not show up as activity on the outlet's P&L. Only inflows from outside (purchases, transfers from other outlets) and outflows out of the outlet (sales, wastage, staff meals, supplier returns) move the outlet's stock value.

Per-location is secondary

Per-location balances are still useful, but they are a secondary view used for internal logistics and cross-checking. The model does not require per-location balances to be physically consistent at every moment: a recipe at the bar can deplete an ingredient that physically lives in the kitchen, because in practice a runner has just walked it across. The bar location may show a negative balance for that ingredient until the next intra-outlet transfer is recorded. This is intentional, and it is the right behaviour — the alternative would be to refuse to sell drinks until paperwork catches up with reality, which is not how kitchens work.

Per-location negative balances are therefore normal in this model, and the product does not clamp them to zero in reports. The outlet-level total is always the authoritative balance. Per-location negative balances are still surfaced — usually as a soft signal in the location detail view — so an attentive store manager can prompt the team to record the missing transfer before period close, but they do not block sales or invalidate anything upstream.

Worked example

Restaurant A has Kitchen and Bar as two internal locations. A purchase delivers 10 kg of LAMB SHOULDER to Kitchen. A bar cocktail recipe (a slow-braised lamb shoulder garnish — humour us) sells five times in the evening, depleting 1 kg total against the bar location. No internal transfer was recorded.

# Per-location view:
#   Kitchen: +10 kg INVOICE_IN, balance +10 kg
#   Bar:     -1 kg RECIPE_OUT,  balance -1 kg
# Outlet (Restaurant A) total:
#   +10 kg - 1 kg = +9 kg

The outlet-level on-hand of 9 kg is correct and reflects physical reality. The bar's −1 kg is a paperwork artefact, not a problem. When the team eventually logs a 1 kg TRANSFER_OUT from Kitchen to Bar, both per-location balances normalise to Kitchen +9 kg and Bar 0 kg, and the outlet total is unchanged at +9 kg.

8. Variance approval and count sessions

A stock count is the moment you compare what the ledger thinks you have against what is physically on the shelf. The gap between the two is variance, and how that variance is recorded determines whether your reports stay honest. EYP Ops takes a structured, multi-step approach to count sessions.

Blind count

When a count session is in progress, the system-expected quantity for each item is hidden from the counter by default. The counter writes only the physical quantity they observed. This is what is meant by a blind count: the counter cannot subconsciously round their number toward what they expect the answer to be, because they do not know the answer. The system-expected quantity becomes visible only after the counter has saved their physical figure, at the variance review step.

Reason codes

Every variance line that a manager confirms must carry a reason code:

  • WASTAGE — item was wasted but not logged at the time of wastage (preferred fix: log wastage in real time).
  • MISCOUNT — the prior count or this count was inaccurate; no actual loss.
  • THEFT — suspected pilferage; usually triggers a security review.
  • RECIPE_GAP — recipe consumes more or less than configured; theoretical cost is wrong, fix the recipe.
  • INVENTORY_TRANSIT — item is in transit between locations and was double- or under-counted because of timing.
  • OTHER — none of the above; manager must add a free-text note.

These codes turn the variance report from a pile of mystery deltas into an actionable diagnostic: in any given month, the operator can see how much variance was wastage that should have been logged earlier, how much was a recipe configuration problem, how much was potential loss. Each category implies a different management response.

Approval and posting

When a count session is reviewed, the variance lines are summed and compared against a configurable threshold per outlet. Below the threshold, the counter or store manager can post directly. Above the threshold — the count revealed material variance — a second role (the operations or finance manager, depending on configuration) must approve before the session can post. This is two-eyes approval, and it exists for the same reason it exists in banking: a single mistake or a single bad actor cannot move money or stock in a high-impact way.

What posting actually writes

Posting a count session does two things in the ledger. First, it sets the count session as the new on-hand reset point, so subsequent on-hand queries treat the counted quantity as the starting figure. Second, where the count differed from the system-expected quantity, it writes ADJ_IN or ADJ_OUT rows for the variance, with the reason code attached. These ADJ rows are how variance reaches actual food cost — they are real consumption (or real over-count corrections) that did not have a more specific source document.

Outlet-wide vs single-location counts

An outlet-wide count is one where every location in the outlet is counted in the same session. When this kind of count posts, locations that were not explicitly counted but had system-expected stock get a phantom zero-out ADJ row, so the outlet-level total reconciles to the counted total without leaving uncounted locations as silent contributors. A single-location count, by contrast, only resets the location that was counted; the others are unaffected. The product makes the choice between the two explicit at session creation, because the implications are very different.

Worked example

At month-end, the count for LAMB SHOULDER at Kitchen returns 18 kg physical. The system-expected quantity, computed from the ledger since the last count, was 30 kg. Variance is 12 kg. The store manager opens the variance review screen and breaks the 12 kg into two reason lines: 8 kg WASTAGE (a batch went bad over the weekend; the wastage was not logged at the time), 4 kg MISCOUNT (the previous count was in fact 4 kg high, confirmed by retracing the count sheet). The operations manager approves the session — variance is above threshold. Posting writes ADJ_OUT 8 kg with reason WASTAGE and ADJ_OUT 4 kg with reason MISCOUNT into the ledger. Actual food cost for the period absorbs both. The variance report now shows 8 kg of unrecorded wastage at Kitchen for the month — and the manager can take that as a signal to tighten real-time wastage logging next month.

A closing note on auditability

Everything on this page is implemented in production today. Every field name we mentioned exists in the schema. Every formula above is the formula the code runs. We are documenting it publicly because a methodology page is one of the few pieces of marketing material that should be plagiarism-proof: another vendor cannot copy this and stand behind it unless their system actually works this way. If anything here is unclear, or you want to see the relevant code path, write to [email protected] and we will walk you through it.