Skip to content

feat: add ingredient cost tracking and profitability reporting - #1332

Merged
khaira777 merged 7 commits into
mainfrom
fm/scout-1283-ingredient-cost-design
Oct 23, 2026
Merged

khaira777 merged 7 commits into
mainfrom
fm/scout-1283-ingredient-cost-design

Conversation

@khaira777

@khaira777 khaira777 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Implement ingredient cost and profitability reporting (#1283): track unit purchase costs on supplies with weighted average cost (WAC) on stock receives, derive menu item ingredient cost from recipes and freeze it onto order_items at sale time, and show revenue, ingredient cost, profit, and margin per product and for the period in sales reports and daily sales exports.

What Changed

  • Added unit_cost tracking on supplies and unit_cost, total_cost, and unit_cost_after on supply_movements (migration 117), updating supply unit costs via weighted average cost (WAC) on priced receive movements (or resetting when prior stock is <= 0 or unpriced) and validating supplies[].unit_cost on database imports.
  • Extended buildRecipeSnapshot() and scaleRecipeSnapshot() to compute recipe component and line ingredient costs from active recipes and variant recipe_multiplier values, freezing ingredient_cost and ingredient_cost_known onto order_items across order creation, item append, quantity-reduction edits, and void_adjustment refund reversals.
  • Added ex-tax revenue, ingredient cost, gross profit, margin percentage, and cost coverage status (known, partial, unknown) to /api/reports/financial-summary, /api/reports/topProducts, daily sales CSV/XLSX exports, and the Inventory and Dashboard UIs with translations across all supported locales.

Risk Assessment

✅ Low: All Round 1 review findings were cleanly addressed, and the ingredient cost tracking, WAC calculation, frozen order-item cost snapshots, and profitability reporting/export changes are well-bounded and consistent with repository invariants.

Testing

Built the backend and static desktop frontend, ran 10 targeted backend/integration suites (including 115 assertions in tests/ingredient-cost-lifecycle.test.ts), and drove the real running product via Playwright on isolated ports 43101-43103 to capture live visual and export evidence across /inventory, /dashboard, /api/reports/topProducts, and /api/reports/daily-sales/export.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Create supplies with unit purchase costs, receive stock in purchase units with batch total cost or unit cost, blend via weighted average cost (WAC), and render sub-minor-unit gram/ml costs in per-kg/p… ✅ pass live 01-inventory-supplies-wac.png, 02-inventory-movements-cost.png, tests/ingredient-cost-lifecycle.test.ts
Build product recipes with ingredient components: show 'Cost unknown' when any ingredient lacks a unit cost (never partial cost understatement), and display derived recipe cost, profit, and margin onc… ✅ pass live 03-inventory-recipes-partial-unknown.png, 04-inventory-recipes-known-margin.png, tests/ingredient-cost-lifecycle.test.ts
Freeze recipe ingredient cost onto order_items at sale time, keep historical order item costs immutable when supply costs or recipes later change, and scale frozen cost proportionally on item quantity… ✅ pass live 05-dashboard-profitability-cards-and-top-products.png, live-api-and-export-verification.json, tests/ingredient-cost-lifecycle.test.ts
Render period profitability KPI cards (Ingredient Cost, Gross Profit, Gross Margin, Partial cost badge) and Top Products Today breakdown on /dashboard, and export known/partial/unknown profitability m… ✅ pass live 05-dashboard-profitability-cards-and-top-products.png, daily-sales-summary.csv, daily-sales-items.csv, tests/ingredient-cost-lifecycle.test.ts
Enforce authorization, validation, and contract boundaries: redact cost/profit/margin from /api/reports/topProducts when caller lacks reports.financial.view, reject negative unit_cost on supplies/move… ✅ pass live live-api-and-export-verification.json, tests/ingredient-cost-lifecycle.test.ts

Inventory Supplies Table - Weighted Average Cost & Sub-Minor Unit per-kg Label
Inventory Movements Table - Receive Batch Cost Display
Inventory Recipes Table - Partial/Uncosted Component Shows Cost Unknown Badge
Inventory Recipes Table - Known Recipe Cost, Profit, and Margin Preview
Dashboard Profitability KPI Cards & Top Products Today Breakdown

Evidence: Daily Sales Summary CSV Export with Period Profitability Metrics

metric,value business_date,2026-10-23 timezone,Asia/Bangkok business_day_start,00:00 currency,THB order_count,2 paid_bill_count,2 gross_collected,342.4 refunds_issued,0 net_collected,342.4 tax_total,22.4 discount_total,0 service_charge_total,0 packaging_charge_total,0 delivery_charge_total,0 known_cost_net_item_sales,220 ingredient_cost_total,36.4 gross_profit_total,183.6 gross_margin_percent,83.45 cost_coverage_status,partial payment_card,342.4

metric,value
business_date,2026-10-23
timezone,Asia/Bangkok
business_day_start,00:00
currency,THB
order_count,2
paid_bill_count,2
gross_collected,342.4
refunds_issued,0
net_collected,342.4
tax_total,22.4
discount_total,0
service_charge_total,0
packaging_charge_total,0
delivery_charge_total,0
known_cost_net_item_sales,220
ingredient_cost_total,36.4
gross_profit_total,183.6
gross_margin_percent,83.45
cost_coverage_status,partial
payment_card,342.4
Evidence: Daily Sales Items CSV Export with Per-Product Cost, Profit, Margin, and Cost Status

product_id,product_name,product_sku,quantity,gross_item_sales,item_discounts,net_item_sales,tax_amount,ingredient_cost,profit,margin_percent,cost_status e2e-product,E2E Coffee,,3,180,0,180,12.600000000000001,26.4,93.6,78,partial yfrl4i,Iced Matcha,,1,107,0,107,7,10,90,90,known uqyfeq,Bottled Sparkling Water,,1,40,0,40,2.8,,,,unknown

product_id,product_name,product_sku,quantity,gross_item_sales,item_discounts,net_item_sales,tax_amount,ingredient_cost,profit,margin_percent,cost_status
e2e-product,E2E Coffee,,3,180,0,180,12.600000000000001,26.4,93.6,78,partial
yfrl4i,Iced Matcha,,1,107,0,107,7,10,90,90,known
uqyfeq,Bottled Sparkling Water,,1,40,0,40,2.8,,,,unknown
Evidence: Live Top Products API Payload (Owner vs Manager Redacted Without reports.financial.view)
{
  "date": "2026-10-23",
  "ownerTopProducts": {
    "topProducts": [
      {
        "product_id": "e2e-product",
        "product_name": "E2E Coffee",
        "total_quantity": 3,
        "total_revenue": 180,
        "order_count": 2,
        "net_revenue": 180,
        "known_cost_revenue": 120,
        "known_cost_quantity": 2,
        "unknown_cost_quantity": 1,
        "cost_status": "partial",
        "ingredient_cost": 26.4,
        "profit": 93.6,
        "margin_percent": 78
      },
      {
        "product_id": "yfrl4i",
        "product_name": "Iced Matcha",
        "total_quantity": 1,
        "total_revenue": 107,
        "order_count": 1,
        "net_revenue": 100,
        "known_cost_revenue": 100,
        "known_cost_quantity": 1,
        "unknown_cost_quantity": 0,
        "cost_status": "known",
        "ingredient_cost": 10,
        "profit": 90,
        "margin_percent": 90
      },
      {
        "product_id": "uqyfeq",
        "product_name": "Bottled Sparkling Water",
        "total_quantity": 1,
        "total_revenue": 40,
        "order_count": 1,
        "net_revenue": 40,
        "known_cost_revenue": 0,
        "known_cost_quantity": 0,
        "unknown_cost_quantity": 1,
        "cost_status": "unknown",
        "ingredient_cost": null,
        "profit": null,
        "margin_percent": null
      }
    ],
    "profitability": {
      "total_quantity": 5,
      "net_revenue": 320,
      "known_cost_revenue": 220,
      "known_cost_quantity": 3,
      "unknown_cost_quantity": 2,
      "cost_status": "partial",
      "ingredient_cost": 36.4,
      "profit": 183.6,
      "margin_percent": 83.45
    }
  },
  "managerTopProductsRedacted": {
    "topProducts": [
      {
        "product_id": "e2e-product",
        "product_name": "E2E Coffee",
        "total_quantity": 3,
        "total_revenue": 180,
        "order_count": 2
      },
      {
        "product_id": "yfrl4i",
        "product_name": "Iced Matcha",
        "total_quantity": 1,
        "total_revenue": 107,
        "order_count": 1
      },
      {
        "product_id": "uqyfeq",
        "product_name": "Bottled Sparkling Water",
        "total_quantity": 1,
        "total_revenue": 40,
        "order_count": 1
      }
    ]
  }
}
Evidence: Ingredient Cost Lifecycle Test Suite (115 assertions)

Source: Ingredient Cost Lifecycle Test Suite (115 assertions)

115/115 passed, 0 failed (upgrade migration v116->v117, WAC & negative stock reset, sub-minor-unit 0/2/3-decimal currency rounding & country fallback, order item cost freezing & immutability across pending/started quantity reductions, single-item refund void_adjustment cost reversal, topProducts & financial-summary & daily-sales CSV/XLSX exports, snake_case supply route contract, and /api/db/import negative unit_cost rejection)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed ✅
  • ⚠️ frontend/src/app/(dashboard)/inventory/page.tsx:577 - In the Supplies table on the Inventory page (frontend/src/app/(dashboard)/inventory/page.tsx:574-583), the unit cost cell renders fmt(Number(s.unit_cost)) / ${s.base_unit} using useFormatCurrency(), which rounds to the tenant currency's minor unit (0, 2, or 3 decimal places). Because s.unit_cost is stored per base_unit (g, ml, etc.) with up to 10 decimal places to preserve sub-minor-unit precision: (1) for a 2-decimal currency (e.g. USD) with a supply (base_unit = &#39;g&#39;) costing $2.00 / kg (s.unit_cost = 0.002), the primary bold label renders $0.00 / g above the secondary ($2.00 / kg) hint; and (2) for a 0-decimal currency (e.g. JPY) with a supply (base_unit = &#39;g&#39;) costing ¥250 / kg (s.unit_cost = 0.25, matching tests/ingredient-cost-lifecycle.test.ts:1873-1906), fmt(0.25) rounds to ¥0 / g AND the secondary per-kg/per-L hint at frontend/src/app/(dashboard)/inventory/page.tsx:578 is gated on Number(s.unit_cost) &lt; 0.1 (false for 0.1 &lt;= unit_cost &lt; 0.5), so the cell displays only ¥0 / g. Confirm whether sub-minor-unit base-unit costs should display the scaled per-kg/per-L rate whenever s.unit_cost is below 1 minor unit of the active currency (or format sub-minor-unit base costs with significant decimals).
  • ⚠️ main/services/supplies.ts:23 - resolveDbCurrencyFractionDigits(db) (main/services/supplies.ts:23-32, called at main/services/supplies.ts:280,518 and main/services/recipes.ts:146,365) introduces a second currency-lookup path that queries only SELECT value FROM settings WHERE key = &#39;currency&#39; and passes row?.value ?? &#39;&#39; to getCurrencyFractionDigits, whereas getTenantCurrency(db) (main/services/refund.ts:34-42, used by main/routes/reports.ts:388,721 and main/services/daily-sales-export.ts:154-156) resolves the currency from both settings.currency and settings.country via resolveTenantCurrency. If a store has settings.country configured (for example &#39;JP&#39; or &#39;KW&#39;) without an explicit settings.currency row, resolveDbCurrencyFractionDigits falls back to 2 fraction digits while reports and daily sales exports use 0 (JPY) or 3 (KWD) fraction digits. Recommend simplifying resolveDbCurrencyFractionDigits(db) to resolve the currency via getTenantCurrency(db) first (falling back to row?.value ?? &#39;&#39; in its catch block for unconfigured unit-test databases).
  • ⚠️ main/routes/supplies.ts:128 - Simplification: POST /api/supplies (main/routes/supplies.ts:128-129), PUT /api/supplies/:id (main/routes/supplies.ts:201-202), and POST /api/supplies/:id/movements (main/routes/supplies.ts:242-243) accept camelCase request-body fallbacks (body.unitCost, body.unitCostUnit, body.totalCost) alongside snake_case (body.unit_cost, body.unit_cost_unit, body.total_cost). Every other field on these endpoints (base_unit, current_stock, min_stock, movement_type, etc.) and all callers (frontend/src/app/(dashboard)/inventory/page.tsx:249-250,331-334 and tests/ingredient-cost-lifecycle.test.ts) use snake_case only. Recommend removing the unrequired camelCase fallbacks and reading body.unit_cost, body.unit_cost_unit, and body.total_cost directly.
  • ⚠️ main/routes/orders.ts:5661 - Simplification: In PATCH /api/orders/:orderId/items/:itemId/edit (main/routes/orders.ts:5661-5668), when scaledRecipeSnapshot is null, an extra fallback branch checks Number(oldItem.ingredient_cost_known) === 1 &amp;&amp; typeof oldItem.ingredient_cost === &#39;number&#39; and scales oldItem.ingredient_cost * heldRatio directly. Under the stated design and invariant 9 (docs/reference/product-invariants.md:107-129), order_items.ingredient_cost is derived exclusively from recipe_snapshot via extractOrderItemCostFromSnapshot, which all other write paths (insertOrderItems at main/routes/orders.ts:983, POST /api/orders/:orderId/items at main/routes/orders.ts:2000, and the sibling pending-decrease path at main/routes/orders.ts:5772-5775) use unconditionally. Recommend removing the non-snapshot fallback branch at main/routes/orders.ts:5661-5668 and calling extractOrderItemCostFromSnapshot(scaledRecipeSnapshot) directly.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 5 of 5 scenarios driven live against the product
Scenario Result Live Evidence
Create supplies with unit purchase costs, receive stock in purchase units with batch total cost or unit cost, blend via weighted average cost (WAC), and render sub-minor-unit gram/ml costs in per-kg/p… ✅ pass live 01-inventory-supplies-wac.png, 02-inventory-movements-cost.png, tests/ingredient-cost-lifecycle.test.ts
Build product recipes with ingredient components: show 'Cost unknown' when any ingredient lacks a unit cost (never partial cost understatement), and display derived recipe cost, profit, and margin onc… ✅ pass live 03-inventory-recipes-partial-unknown.png, 04-inventory-recipes-known-margin.png, tests/ingredient-cost-lifecycle.test.ts
Freeze recipe ingredient cost onto order_items at sale time, keep historical order item costs immutable when supply costs or recipes later change, and scale frozen cost proportionally on item quantity… ✅ pass live 05-dashboard-profitability-cards-and-top-products.png, live-api-and-export-verification.json, tests/ingredient-cost-lifecycle.test.ts
Render period profitability KPI cards (Ingredient Cost, Gross Profit, Gross Margin, Partial cost badge) and Top Products Today breakdown on /dashboard, and export known/partial/unknown profitability m… ✅ pass live 05-dashboard-profitability-cards-and-top-products.png, daily-sales-summary.csv, daily-sales-items.csv, tests/ingredient-cost-lifecycle.test.ts
Enforce authorization, validation, and contract boundaries: redact cost/profit/margin from /api/reports/topProducts when caller lacks reports.financial.view, reject negative unit_cost on supplies/move… ✅ pass live live-api-and-export-verification.json, tests/ingredient-cost-lifecycle.test.ts
  • npm run build
  • npm run build:frontend
  • npm run test:ingredient-cost (115/115 passed)
  • npm run test:recipe-order-lifecycle (35/35 passed)
  • npm run test:supplies-service (39/39 passed)
  • npm run test:daily-sales-export (86/86 passed)
  • npm run test:order-item-edit (147/147 passed)
  • npm run test:refunds (30/30 passed)
  • npm run test:refund-completed-orders (43/43 passed)
  • npm run test:backup (71/71 passed)
  • npm run test:database-tools-api (35/35 passed)
  • npm run test:script-coverage (passed)
  • Playwright live E2E verification against isolated FloCafe stack on ports 43101/43102/43103 (1 passed)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@coderabbitai

coderabbitai Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 93b4b28 and c3349b1.

📒 Files selected for processing: 44
  • docs/reference/api.md
  • docs/reference/daily-sales-export.md
  • docs/reference/product-invariants.md
  • frontend/e2e/database-export-import.spec.ts
  • frontend/src/app/(dashboard)/dashboard/page.tsx
  • frontend/src/app/(dashboard)/inventory/page.tsx
  • frontend/src/lib/i18n/messages/ar.json
  • frontend/src/lib/i18n/messages/bn.json
  • frontend/src/lib/i18n/messages/de.json
  • frontend/src/lib/i18n/messages/en.json
  • frontend/src/lib/i18n/messages/es.json
  • frontend/src/lib/i18n/messages/fa.json
  • frontend/src/lib/i18n/messages/fil.json
  • frontend/src/lib/i18n/messages/fr.json
  • frontend/src/lib/i18n/messages/hi.json
  • frontend/src/lib/i18n/messages/id.json
  • frontend/src/lib/i18n/messages/it.json
  • frontend/src/lib/i18n/messages/ja.json
  • frontend/src/lib/i18n/messages/ko.json
  • frontend/src/lib/i18n/messages/ne.json
  • frontend/src/lib/i18n/messages/nl.json
  • frontend/src/lib/i18n/messages/pt.json
  • frontend/src/lib/i18n/messages/ru.json
  • frontend/src/lib/i18n/messages/sq.json
  • frontend/src/lib/i18n/messages/th.json
  • frontend/src/lib/i18n/messages/tr.json
  • frontend/src/lib/i18n/messages/ur.json
  • frontend/src/lib/i18n/messages/vi.json
  • frontend/src/lib/i18n/messages/zh-tw.json
  • frontend/src/lib/i18n/messages/zh.json
  • main/db.ts
  • main/routes/database.ts
  • main/routes/orders.ts
  • main/routes/reports.ts
  • main/routes/supplies.ts
  • main/services/daily-sales-export-files.ts
  • main/services/daily-sales-export.ts
  • main/services/recipes.ts
  • main/services/refund.ts
  • main/services/supplies.ts
  • package.json
  • tests/backup-restore-production.test.ts
  • tests/expense-records.test.ts
  • tests/ingredient-cost-lifecycle.test.ts
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

khaira777 and others added 2 commits October 23, 2026 13:14
…ite (Shard 3/4)` and `Playwright E2E (Shard 1/4)` (which also cascaded to `End-to-End Playwright Tests` and `Required Checks Gate`): 1. **`Core Test Suite (Shard 3/4)` (`tests/expense-records.test.ts`)**: - **Cause**: Migration v117 (`add_ingredient_cost_tracking`) adds `unit_cost` to `supplies` and `unit_cost`, `total_cost`, and `unit_cost_after` to `supply_movements` (as well as `ingredient_cost` and `ingredient_cost_known` to `order_items`). In `tests/expense-records.test.ts`, `preservedSelections` projected prior columns only for `order_items` while using `SELECT *` for `orders`, `bills`, `supplies`, `supply_movements`, `cash_sessions`, and `cash_drawer_movements`, causing `SELECT * FROM supplies` after upgrading from v116 (`PRIOR_VERSION`) to v117 (`LATEST_VERSION`) to include `"unit_cost":null`. - **Fix**: Replaced `priorOrderItemColumns` and `SELECT *` with `selectPriorColumns(table)` (using the existing `columns(db, table)` helper at `PRIOR_VERSION`) across all preserved tables in `tests/expense-records.test.ts`. 2. **`Playwright E2E (Shard 1/4)` (`frontend/e2e/database-export-import.spec.ts`)**: - **Cause**: `Database import accepts contaminated payloads and rebuilds FTS search indexes` tested a same-version merge import using a hardcoded `schema_version: '116'`. After adding migration v117, `DatabaseSettingsTab` and `POST /api/db/import` treated `116 !== 117` as a cross-version schema mismatch requiring a Master PIN. - **Fix**: Updated `contaminatedPayload.schema_version` from `'116'` to `'117'` in `frontend/e2e/database-export-import.spec.ts`. 3. **Verification**: - `npm run test:expenses && npm run test:addon-inventory-db && npm run test:staff-approvers-directory` (passed) - `npm run test:release-regressions` (passed) - `cd frontend && CI=true npx playwright test --config=playwright.config.ts --project=chromium --retries=0 e2e/database-export-import.spec.ts` (2/2 passed) - `cd frontend && CI=true npx playwright test --config=playwright.config.ts --project=chromium --retries=0 --shard=1/4 --grep='@ci-tier1|@ci-tier2' --grep-invert='FINDING:|a paid order offers a refund, and issuing it settles against the recorded sale'` (22/22 passed) - `npm run build && npm run build:frontend` (passed) - `npm run lint && npm run lint:budget && npx oxfmt --check tests/expense-records.test.ts && (cd frontend && npx oxfmt --check e2e/database-export-import.spec.ts) && git diff --check` (passed)
@khaira777
khaira777 enabled auto-merge (squash) October 23, 2026 17:24
@khaira777
khaira777 merged commit 8e39053 into main Oct 23, 2026
22 of 23 checks passed
@khaira777
khaira777 deleted the fm/scout-1283-ingredient-cost-design branch October 23, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant