Skip to content

Part 7 of 7 · Menu cost calculator series ~7 min read

Engineering reference: the menu cost calculator architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the stamped costing, and the yield model.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The menu cost calculator drawn with AWS service namesThree boxes across the top outside the AWS account. Supplier invoices, arriving as a file or a scan. Recipes and yields, versioned. And Sales by dish, from the till. Inside the account, three groups. S3 receiving invoices alongside EventBridge running a weekly recost. Three Lambda functions named prices, cost and report. And two DynamoDB tables named ingredients and costings. A note gives the region as us-east-1, one account, and states that costings copy their inputs in and recipes are versioned rather than edited.AWS ACCOUNTSupplier invoicesfile or scanRecipes and yieldsversionedSales by dishfrom the tillS3 + EventBridgeinvoices,weekly recostLambda x3prices, cost, reportDynamoDB x2ingredients, costingsingroundsoutus-east-1. One account. Costings copy their inputs in; recipes are versioned, never edited.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • Analytics
  • Front-end & mobile

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
mc-pricesS3 put on the invoices prefixExtracts lines from a structured file, or reads a scan; updates the weighted average price180s / 1024 MB
mc-costEventBridge, weeklyRecosts every dish at current prices and yields; writes an immutable costing per dish300s / 1024 MB
mc-reportEventBridge, monthlyJoins costings to sales; decomposes cost changes; computes theoretical against actual usage300s / 1024 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
mc-prices-roles3:GetObject, bedrock:InvokeModel, dynamodb:UpdateItemThe invoices prefix; one model id; ingredients
mc-cost-roledynamodb:Query, dynamodb:PutItemIngredients; put only on costings
mc-report-roledynamodb:QueryRead-only across both tables

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: ingredients

PK   ingredient_id     S   beef_fillet
     unit              S   kg
     price_pence       N   weighted average of recent deliveries
     price_latest      N   most recent invoice, for the step-change flag
     trim_yield        N   0.68 — measured, with a date
     cook_yield        N   0.82 — applied only where the recipe says cooked
     yield_measured_at S   null means it came from a table, not a scale
     trim_credit_pence N   0 unless the offcuts are genuinely used
     supplier          S   yields are per supplier, not per ingredient

`yield_measured_at` being null is surfaced on every costing that uses
it, so a book figure is never mistaken for a measurement.

Table: costings

PK   dish_id           S
SK   costed_on         S   2026-08-25 — weekly, immutable
     recipe_version    S   v4
     lines             L   [{ingredient, qty, unit, price_used,
                             yield_used, basis: raw|cooked, cost}]
     plate_cost_pence  N
     menu_price_pence  N
     gp_pct            N
     margin_pence      N   reported next to gp_pct, always
     stale             BOOL true if any input is over 30 days old

`lines` copies price_used and yield_used in rather than referencing
them. That copy is what makes two costings comparable months apart.

Inbound and outbound

  • Structured invoice files are parsed directly. Only scans reach the model, and moving a supplier onto a file removes that cost permanently.
  • Prices are a weighted average over recent deliveries, with a flag when the latest invoice diverges enough to suggest a step change.
  • Yields are per ingredient per supplier, with the measurement date. A supplier change invalidates the yield.
  • Recipes are versioned. A portion change is a new version, so sales and costings both join to the version that was actually in use.

The model call

  • One read per scanned invoice. Extracting supplier, date, lines, quantities, units and prices.
  • It never estimates a yield. An unmeasured yield is flagged on every costing that depends on it, which is what gets it measured.
  • It never suggests a price. Menu pricing needs the room, the competition and what the dish is for, none of which are in this data.
  • Unit conversion is code, not a model. Cases to kilos, litres to millilitres: a lookup that must be right rather than probably right.
  • The cost page assumes one read per invoice, which falls as suppliers move to files.

Things worth knowing before you build it

  • Copy prices and yields into the costing rather than referencing them. A costing that recomputes itself has no history and no attribution.
  • Apply cooking loss only where the recipe quantity is a cooked weight. Applying both losses to a raw-weight recipe overstates the cost by around twenty per cent.
  • Keep yields per supplier and re-measure when the supplier changes. A specification change is a real cost movement with no price movement attached.
  • Report cash margin next to gross profit percentage everywhere. A menu managed on percentage promotes coffee over steak.
  • Grey out dishes below about twenty sales in the period. The most dramatic lines on any menu report are always the least-sold ones.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts