From a92fbfb2e9ca853d5be91ae8cd19f6a2ff0d4fae Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 11:03:53 -0400 Subject: [PATCH 1/6] feat(inventory): track supply unit costs and freeze recipe cost on order items (#1283) --- main/db.ts | 29 ++ main/routes/database.ts | 16 + main/routes/orders.ts | 64 ++- main/routes/supplies.ts | 6 + main/services/recipes.ts | 220 ++++++++- main/services/refund.ts | 15 +- main/services/supplies.ts | 184 ++++++- package.json | 3 +- tests/backup-restore-production.test.ts | 4 +- tests/ingredient-cost-lifecycle.test.ts | 625 ++++++++++++++++++++++++ 10 files changed, 1104 insertions(+), 62 deletions(-) create mode 100644 tests/ingredient-cost-lifecycle.test.ts diff --git a/main/db.ts b/main/db.ts index 7f59627e6..75696c9e4 100644 --- a/main/db.ts +++ b/main/db.ts @@ -7664,6 +7664,35 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ `); }, }, + { + version: 117, + name: 'add_ingredient_cost_tracking', + up: () => { + const supplyColumns = getColumns(db, 'supplies'); + if (!supplyColumns.includes('unit_cost')) { + db.exec('ALTER TABLE supplies ADD COLUMN unit_cost REAL DEFAULT NULL'); + } + + const movementColumns = getColumns(db, 'supply_movements'); + const addMovementCol = (name: string, definition: string) => { + if (!movementColumns.includes(name)) { + db.exec(`ALTER TABLE supply_movements ADD COLUMN ${name} ${definition}`); + } + }; + addMovementCol('unit_cost', 'REAL DEFAULT NULL'); + addMovementCol('total_cost', 'REAL DEFAULT NULL'); + addMovementCol('unit_cost_after', 'REAL DEFAULT NULL'); + + const orderItemColumns = getColumns(db, 'order_items'); + const addOrderItemCol = (name: string, definition: string) => { + if (!orderItemColumns.includes(name)) { + db.exec(`ALTER TABLE order_items ADD COLUMN ${name} ${definition}`); + } + }; + addOrderItemCol('ingredient_cost', 'REAL DEFAULT NULL'); + addOrderItemCol('ingredient_cost_known', 'INTEGER NOT NULL DEFAULT 0'); + }, + }, ]; function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void { diff --git a/main/routes/database.ts b/main/routes/database.ts index 6a38967a5..1b3a8111a 100644 --- a/main/routes/database.ts +++ b/main/routes/database.ts @@ -252,6 +252,22 @@ router.post( }); } + const invalidSupplyCostIndex = Array.isArray(importData.supplies) + ? importData.supplies.findIndex( + (supply) => + supply?.unit_cost !== undefined && + supply?.unit_cost !== null && + (typeof supply.unit_cost !== 'number' || + !Number.isFinite(supply.unit_cost) || + supply.unit_cost < 0), + ) + : -1; + if (invalidSupplyCostIndex >= 0) { + return res.status(400).json({ + error: `supplies[${invalidSupplyCostIndex}].unit_cost must be a non-negative finite number or null`, + }); + } + if (Array.isArray(importData.products) && importData.products.length > 0) { if (!Array.isArray(importData.inventory_movements)) { const legacyZeroStockImport = diff --git a/main/routes/orders.ts b/main/routes/orders.ts index cad24a7ca..395660b6b 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -48,7 +48,13 @@ import { variantUnitPrice, type ProductVariant, } from '../services/product-variants'; -import { applyRecipeSnapshot, buildRecipeSnapshot, parseRecipeSnapshot } from '../services/recipes'; +import { + applyRecipeSnapshot, + buildRecipeSnapshot, + extractOrderItemCostFromSnapshot, + parseRecipeSnapshot, + scaleRecipeSnapshot, +} from '../services/recipes'; import { carryPriceOverride, ITEM_PRICE_OVERRIDE_PERMISSION, @@ -864,8 +870,8 @@ function insertOrderItems( const insertItem = db.prepare(` INSERT INTO order_items (order_id, product_id, variant_id, product_name, product_sku, unit_price, quantity, inventory_deducted_quantity, inventory_product_id, inventory_variant_id, subtotal, tax_amount, tax_breakdown, tax_snapshot, tax_type, discount_amount, total, variant_selection, - modifier_selection, special_instructions, recipe_snapshot, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + modifier_selection, special_instructions, recipe_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertedItemIds: (number | bigint)[] = []; @@ -974,6 +980,8 @@ function insertOrderItems( context.preserveSelections && item.recipe_snapshot !== undefined ? (item.recipe_snapshot as ReturnType) : buildRecipeSnapshot(db, product.id, quantity, variant?.recipe_multiplier ?? 1); + const { ingredientCost, ingredientCostKnown } = + extractOrderItemCostFromSnapshot(recipeSnapshot); const insertItemResult = insertItem.run( orderId, product.id, @@ -1000,6 +1008,8 @@ function insertOrderItems( JSON.stringify(item.modifier_selection || null), item.special_instructions || null, recipeSnapshot ? JSON.stringify(recipeSnapshot) : null, + ingredientCost, + ingredientCostKnown, initialStatus, itemCreatedAt, itemCreatedAt, @@ -1115,6 +1125,8 @@ interface OrderItemRow { inventory_product_id: string | null; inventory_variant_id: string | null; recipe_snapshot: string | null; + ingredient_cost?: number | null; + ingredient_cost_known?: number; updated_at: string; } @@ -1763,8 +1775,8 @@ router.post( const insertItem = db.prepare(` INSERT INTO order_items (order_id, product_id, variant_id, product_name, product_sku, unit_price, quantity, inventory_deducted_quantity, inventory_product_id, inventory_variant_id, subtotal, tax_amount, tax_breakdown, tax_snapshot, tax_type, discount_amount, total, variant_selection, - modifier_selection, special_instructions, recipe_snapshot, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) + modifier_selection, special_instructions, recipe_snapshot, ingredient_cost, ingredient_cost_known, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?) `); const validatedItems: Array<{ @@ -1940,6 +1952,8 @@ router.post( quantity, variant?.recipe_multiplier ?? 1, ); + const { ingredientCost, ingredientCostKnown } = + extractOrderItemCostFromSnapshot(recipeSnapshot); const insertItemResult = insertItem.run( orderId, product.id, @@ -1964,6 +1978,8 @@ router.post( JSON.stringify(item.modifier_selection || null), item.special_instructions || null, recipeSnapshot ? JSON.stringify(recipeSnapshot) : null, + ingredientCost, + ingredientCostKnown, itemCreatedAt, itemCreatedAt, ); @@ -5634,12 +5650,30 @@ router.patch( // stock returning to available when a back-to-pending line is edited). const heldRatio = newQuantity / oldQuantity; const heldRecipeSnapshot = parseRecipeSnapshot(oldItem.recipe_snapshot); + const scaledRecipeSnapshot = heldRecipeSnapshot + ? scaleRecipeSnapshot(heldRecipeSnapshot, heldRatio, decimals) + : null; + const { + ingredientCost: scaledIngredientCost, + ingredientCostKnown: scaledIngredientCostKnown, + } = scaledRecipeSnapshot + ? extractOrderItemCostFromSnapshot(scaledRecipeSnapshot) + : Number(oldItem.ingredient_cost_known) === 1 && + typeof oldItem.ingredient_cost === 'number' && + Number.isFinite(oldItem.ingredient_cost) + ? { + ingredientCost: Number((oldItem.ingredient_cost * heldRatio).toFixed(decimals)), + ingredientCostKnown: 1 as const, + } + : { ingredientCost: null, ingredientCostKnown: 0 as const }; db.prepare(` UPDATE order_items SET quantity = ?, inventory_deducted_quantity = ?, recipe_snapshot = ?, + ingredient_cost = ?, + ingredient_cost_known = ?, subtotal = ?, tax_amount = ?, tax_breakdown = ?, @@ -5652,15 +5686,9 @@ router.patch( `).run( newQuantity, Number((Number(oldItem.inventory_deducted_quantity) * heldRatio).toFixed(6)), - heldRecipeSnapshot - ? JSON.stringify({ - ...heldRecipeSnapshot, - components: heldRecipeSnapshot.components.map((component) => ({ - ...component, - quantity: Number((component.quantity * heldRatio).toFixed(6)), - })), - }) - : oldItem.recipe_snapshot, + scaledRecipeSnapshot ? JSON.stringify(scaledRecipeSnapshot) : oldItem.recipe_snapshot, + scaledIngredientCost, + scaledIngredientCostKnown, itemSubtotal, taxResult.tax_amount, JSON.stringify(taxResult.tax_breakdown), @@ -5743,13 +5771,7 @@ router.patch( })); const heldRecipeSnapshot = parseRecipeSnapshot(oldItem.recipe_snapshot); newItem.recipe_snapshot = heldRecipeSnapshot - ? { - ...heldRecipeSnapshot, - components: heldRecipeSnapshot.components.map((component) => ({ - ...component, - quantity: Number((component.quantity * heldRatio).toFixed(6)), - })), - } + ? scaleRecipeSnapshot(heldRecipeSnapshot, heldRatio, decimals) : null; const oldTarget = orderItemInventoryTarget(db, oldItem); newItem.inventory_deducted_quantity = Number( diff --git a/main/routes/supplies.ts b/main/routes/supplies.ts index 0d157b848..11873f8f4 100644 --- a/main/routes/supplies.ts +++ b/main/routes/supplies.ts @@ -125,6 +125,8 @@ router.post('/', requirePermission('supplies.manage'), (req: Request, res: Respo baseUnit: body.base_unit, stockQuantity: body.stock_quantity, lowStockThreshold: body.low_stock_threshold, + unitCost: body.unit_cost !== undefined ? body.unit_cost : body.unitCost, + unitCostUnit: body.unit_cost_unit !== undefined ? body.unit_cost_unit : body.unitCostUnit, isActive: body.is_active, actorUserId: actorId, }); @@ -196,6 +198,8 @@ router.put('/:id', requirePermission('supplies.manage'), (req: Request, res: Res name: body.name, isActive: body.is_active, lowStockThreshold: body.low_stock_threshold, + unitCost: body.unit_cost !== undefined ? body.unit_cost : body.unitCost, + unitCostUnit: body.unit_cost_unit !== undefined ? body.unit_cost_unit : body.unitCostUnit, }); res.json({ supply }); } catch (error: unknown) { @@ -235,6 +239,8 @@ router.post( movementType, quantity: body.quantity, unit: body.unit, + unitCost: body.unit_cost !== undefined ? body.unit_cost : body.unitCost, + totalCost: body.total_cost !== undefined ? body.total_cost : body.totalCost, reason: body.reason, actorUserId: actorId, }); diff --git a/main/services/recipes.ts b/main/services/recipes.ts index 59b97c7f4..880219b9e 100644 --- a/main/services/recipes.ts +++ b/main/services/recipes.ts @@ -1,4 +1,5 @@ import { randomBytes } from 'crypto'; +import Decimal from 'decimal.js'; import { getDatabase, now, withTxn } from '../db'; import { SupplyUnit, @@ -7,7 +8,12 @@ import { convertQuantity, roundQuantity, } from './units'; -import { applySupplyStockChange } from './supplies'; +import { + applySupplyStockChange, + resolveDbCurrencyFractionDigits, + roundCurrencyCost, + roundUnitCost, +} from './supplies'; export type RecipeValidationCode = | 'product_missing' @@ -38,19 +44,27 @@ export interface RecipeComponent { quantity: number; unit: SupplyUnit; quantity_in_base: number; + supply_unit_cost: number | null; + component_cost: number | null; +} + +export interface RecipeSnapshotComponent { + supply_id: string; + supply_name: string; + base_unit: SupplyUnit; + quantity: number; + unit_cost?: number | null; + line_cost?: number | null; } export interface RecipeSnapshot { recipe_id: string; product_id: string; yield_quantity: number; + cost_known?: boolean; + total_ingredient_cost?: number | null; /** Scaled amounts for this order item quantity, already in each supply's base unit. */ - components: { - supply_id: string; - supply_name: string; - base_unit: SupplyUnit; - quantity: number; - }[]; + components: RecipeSnapshotComponent[]; } export interface RecipeRecord { @@ -61,6 +75,9 @@ export interface RecipeRecord { is_active: number; created_at: string; updated_at: string; + cost_known: boolean; + total_cost: number | null; + unit_cost: number | null; items: RecipeComponent[]; } @@ -97,6 +114,7 @@ function loadRecipeItems(db: ReturnType, recipeId: string): ri.supply_id, s.name AS supply_name, s.base_unit, + s.unit_cost AS supply_unit_cost, ri.quantity, ri.unit FROM recipe_items ri @@ -108,13 +126,64 @@ function loadRecipeItems(db: ReturnType, recipeId: string): supply_id: string; supply_name: string; base_unit: SupplyUnit; + supply_unit_cost: number | null; quantity: number; unit: SupplyUnit; }[]; - return rows.map((row) => ({ + return rows.map((row) => { + const quantityInBase = convertQuantity(row.quantity, row.unit, row.base_unit); + const supplyUnitCost = + row.supply_unit_cost === null || row.supply_unit_cost === undefined + ? null + : Number(row.supply_unit_cost); + const componentCost = + supplyUnitCost !== null && Number.isFinite(supplyUnitCost) + ? roundUnitCost(new Decimal(quantityInBase).mul(supplyUnitCost)) + : null; + return { + supply_id: row.supply_id, + supply_name: row.supply_name, + base_unit: row.base_unit, + quantity: row.quantity, + unit: row.unit, + quantity_in_base: quantityInBase, + supply_unit_cost: supplyUnitCost, + component_cost: componentCost, + }; + }); +} + +function hydrateRecipeRecord( + db: ReturnType, + row: Omit & { + product_name: string | null; + }, +): RecipeRecord { + const items = loadRecipeItems(db, row.id); + const fractionDigits = resolveDbCurrencyFractionDigits(db); + const costKnown = + items.length > 0 && + items.every( + (item) => typeof item.supply_unit_cost === 'number' && Number.isFinite(item.supply_unit_cost), + ); + const totalCostDecimal = costKnown + ? items.reduce( + (sum, item) => sum.plus(new Decimal(item.quantity_in_base).mul(item.supply_unit_cost!)), + new Decimal(0), + ) + : null; + const totalCost = totalCostDecimal !== null ? roundUnitCost(totalCostDecimal) : null; + const unitCost = + totalCostDecimal !== null + ? roundCurrencyCost(totalCostDecimal.div(row.yield_quantity), fractionDigits) + : null; + return { ...row, - quantity_in_base: convertQuantity(row.quantity, row.unit, row.base_unit), - })); + cost_known: costKnown, + total_cost: totalCost, + unit_cost: unitCost, + items, + }; } export function getRecipeByProduct( @@ -128,9 +197,13 @@ export function getRecipeByProduct( LEFT JOIN products p ON p.id = r.product_id WHERE r.product_id = ? `) - .get(productId) as (Omit & { product_name: string | null }) | undefined; + .get(productId) as + | (Omit & { + product_name: string | null; + }) + | undefined; if (!row) return null; - return { ...row, items: loadRecipeItems(db, row.id) }; + return hydrateRecipeRecord(db, row); } export function getRecipe(db: ReturnType, id: string): RecipeRecord | null { @@ -141,9 +214,13 @@ export function getRecipe(db: ReturnType, id: string): Recip LEFT JOIN products p ON p.id = r.product_id WHERE r.id = ? `) - .get(id) as (Omit & { product_name: string | null }) | undefined; + .get(id) as + | (Omit & { + product_name: string | null; + }) + | undefined; if (!row) return null; - return { ...row, items: loadRecipeItems(db, row.id) }; + return hydrateRecipeRecord(db, row); } export function listRecipes(db: ReturnType): RecipeRecord[] { @@ -154,8 +231,10 @@ export function listRecipes(db: ReturnType): RecipeRecord[] LEFT JOIN products p ON p.id = r.product_id ORDER BY p.name COLLATE NOCASE `) - .all() as (Omit & { product_name: string | null })[]; - return rows.map((row) => ({ ...row, items: loadRecipeItems(db, row.id) })); + .all() as (Omit & { + product_name: string | null; + })[]; + return rows.map((row) => hydrateRecipeRecord(db, row)); } export function saveRecipe( @@ -283,22 +362,119 @@ export function buildRecipeSnapshot( throw new RecipeServiceError(400, 'recipe multiplier must be a positive finite number'); } + const fractionDigits = resolveDbCurrencyFractionDigits(db); const scale = (orderQuantity * recipeMultiplier) / recipe.yield_quantity; - const components = recipe.items.map((item) => ({ - supply_id: item.supply_id, - supply_name: item.supply_name, - base_unit: item.base_unit, - quantity: roundQuantity(item.quantity_in_base * scale), - })); + const components: RecipeSnapshotComponent[] = recipe.items.map((item) => { + const quantity = roundQuantity(item.quantity_in_base * scale); + const unitCost = + typeof item.supply_unit_cost === 'number' && Number.isFinite(item.supply_unit_cost) + ? item.supply_unit_cost + : null; + const lineCost = unitCost !== null ? roundUnitCost(new Decimal(quantity).mul(unitCost)) : null; + return { + supply_id: item.supply_id, + supply_name: item.supply_name, + base_unit: item.base_unit, + quantity, + unit_cost: unitCost, + line_cost: lineCost, + }; + }); + + const costKnown = + components.length > 0 && + components.every( + (component) => + typeof component.unit_cost === 'number' && Number.isFinite(component.unit_cost), + ); + const totalIngredientCost = costKnown + ? roundCurrencyCost( + components.reduce( + (sum, component) => sum.plus(new Decimal(component.quantity).mul(component.unit_cost!)), + new Decimal(0), + ), + fractionDigits, + ) + : null; return { recipe_id: recipe.id, product_id: recipe.product_id, yield_quantity: recipe.yield_quantity, + cost_known: costKnown, + total_ingredient_cost: totalIngredientCost, + components, + }; +} + +export function scaleRecipeSnapshot( + snapshot: RecipeSnapshot, + ratio: number, + fractionDigits: number, +): RecipeSnapshot { + const components: RecipeSnapshotComponent[] = snapshot.components.map((component) => { + const quantity = Number((component.quantity * ratio).toFixed(6)); + const hasUnitCost = + typeof component.unit_cost === 'number' && Number.isFinite(component.unit_cost); + const lineCost = hasUnitCost + ? roundUnitCost(new Decimal(quantity).mul(component.unit_cost!)) + : component.line_cost !== undefined + ? null + : undefined; + return { + ...component, + quantity, + ...(lineCost !== undefined ? { line_cost: lineCost } : {}), + }; + }); + const costKnown = + snapshot.cost_known === true && + components.length > 0 && + components.every( + (component) => + typeof component.unit_cost === 'number' && Number.isFinite(component.unit_cost), + ); + const totalIngredientCost = costKnown + ? roundCurrencyCost( + components.reduce( + (sum, component) => sum.plus(new Decimal(component.quantity).mul(component.unit_cost!)), + new Decimal(0), + ), + fractionDigits, + ) + : snapshot.cost_known !== undefined + ? null + : undefined; + + return { + ...snapshot, + ...(snapshot.cost_known !== undefined ? { cost_known: costKnown } : {}), + ...(totalIngredientCost !== undefined ? { total_ingredient_cost: totalIngredientCost } : {}), components, }; } +export function extractOrderItemCostFromSnapshot(snapshot: RecipeSnapshot | null | undefined): { + ingredientCost: number | null; + ingredientCostKnown: 0 | 1; +} { + if ( + snapshot && + snapshot.cost_known === true && + typeof snapshot.total_ingredient_cost === 'number' && + Number.isFinite(snapshot.total_ingredient_cost) + ) { + return { + ingredientCost: snapshot.total_ingredient_cost, + ingredientCostKnown: 1, + }; + } + return { + ingredientCost: null, + ingredientCostKnown: 0, + }; +} + export function parseRecipeSnapshot(raw: unknown): RecipeSnapshot | null { if (!raw || typeof raw !== 'string') return null; try { diff --git a/main/services/refund.ts b/main/services/refund.ts index 25664817e..a394397d3 100644 --- a/main/services/refund.ts +++ b/main/services/refund.ts @@ -394,13 +394,22 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { if (item) { // Insert negative void_adjustment row to reverse item cost while preserving audit trail. + const reversedIngredientCost = + Number(item.ingredient_cost_known) === 1 && + typeof item.ingredient_cost === 'number' && + Number.isFinite(item.ingredient_cost) + ? item.ingredient_cost === 0 + ? 0 + : -item.ingredient_cost + : null; + const reversedIngredientCostKnown = reversedIngredientCost !== null ? 1 : 0; const adjustmentResult = db .prepare(` INSERT INTO order_items ( order_id, product_id, product_name, product_sku, unit_price, quantity, subtotal, tax_amount, tax_breakdown, tax_snapshot, tax_type, discount_amount, total, - variant_selection, modifier_selection, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'void_adjustment', ?, ?) + variant_selection, modifier_selection, ingredient_cost, ingredient_cost_known, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'void_adjustment', ?, ?) `) .run( item.order_id, @@ -418,6 +427,8 @@ export function createRefund(db: Database, req: RefundRequest): RefundResult { -item.total, item.variant_selection, item.modifier_selection, + reversedIngredientCost, + reversedIngredientCostKnown, timestamp, timestamp, ); diff --git a/main/services/supplies.ts b/main/services/supplies.ts index 1871dbf7d..2fa2cb4a7 100644 --- a/main/services/supplies.ts +++ b/main/services/supplies.ts @@ -1,7 +1,36 @@ import { randomBytes } from 'crypto'; +import Decimal from 'decimal.js'; +import { getCurrencyFractionDigits } from '../countries'; import { getDatabase, now, withTxn } from '../db'; import { SupplyUnit, assertSupplyUnit, convertQuantity, roundQuantity } from './units'; +export const COST_PRECISION = 10; + +export function roundUnitCost(value: number | Decimal): number { + const rounded = new Decimal(value) + .toDecimalPlaces(COST_PRECISION, Decimal.ROUND_HALF_UP) + .toNumber(); + return Object.is(rounded, -0) ? 0 : rounded; +} + +export function roundCurrencyCost(value: number | Decimal, fractionDigits: number): number { + const rounded = new Decimal(value) + .toDecimalPlaces(fractionDigits, Decimal.ROUND_HALF_UP) + .toNumber(); + return Object.is(rounded, -0) ? 0 : rounded; +} + +export function resolveDbCurrencyFractionDigits(db: ReturnType): number { + try { + const row = db.prepare("SELECT value FROM settings WHERE key = 'currency'").get() as + | { value?: string | null } + | undefined; + return getCurrencyFractionDigits(row?.value || ''); + } catch { + return 2; + } +} + export type SupplyMovementType = | 'receive' | 'count' @@ -16,6 +45,7 @@ export type SupplyServiceDiagnosticCode = | 'quantity_invalid' | 'unit_invalid' | 'threshold_invalid' + | 'cost_invalid' | 'movement_reason_required' | 'movement_reason_too_long' | 'movement_quantity_invalid' @@ -40,6 +70,7 @@ export interface SupplyRecord { base_unit: SupplyUnit; stock_quantity: number; low_stock_threshold: number | null; + unit_cost: number | null; is_active: number; deleted_at: string | null; created_at: string; @@ -55,6 +86,9 @@ export interface SupplyMovement { movement_type: SupplyMovementType; unit: SupplyUnit; stock_after: number; + unit_cost: number | null; + total_cost: number | null; + unit_cost_after: number | null; reason: string | null; actor_user_id: string; actor_name: string | null; @@ -69,6 +103,8 @@ export interface CreateSupplyInput { baseUnit: string; stockQuantity?: number; lowStockThreshold?: number | null; + unitCost?: number | null; + unitCostUnit?: string; isActive?: boolean; actorUserId?: string; } @@ -78,6 +114,8 @@ export interface RecordMovementInput { movementType: 'receive' | 'count' | 'adjustment' | 'waste'; quantity: number; unit?: string; + unitCost?: number | null; + totalCost?: number | null; reason?: string | null; actorUserId: string; referenceType?: string | null; @@ -120,6 +158,33 @@ function normalizeQuantity( return quantity; } +function normalizeCost(value: unknown, field = 'unit_cost'): number { + const cost = typeof value === 'string' ? Number(value) : value; + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) { + throw new SupplyServiceError( + 400, + `${field} must be a non-negative finite number`, + 'cost_invalid', + ); + } + return cost; +} + +function normalizeUnitCostInBase( + rawCost: unknown, + costUnitRaw: string | undefined, + baseUnit: SupplyUnit, + field = 'unit_cost', +): number { + const cost = normalizeCost(rawCost, field); + const costUnit = + costUnitRaw === undefined || costUnitRaw === null + ? baseUnit + : assertSupplyUnit(costUnitRaw, `${field}_unit`); + const factorToBase = convertQuantity(1, costUnit, baseUnit); + return roundUnitCost(new Decimal(cost).div(factorToBase)); +} + export function getSupply(db: ReturnType, id: string): SupplyRecord { const row = db.prepare('SELECT * FROM supplies WHERE id = ? AND deleted_at IS NULL').get(id) as | SupplyRecord @@ -195,24 +260,36 @@ export function createSupply( input.lowStockThreshold === undefined || input.lowStockThreshold === null ? null : roundQuantity(normalizeQuantity(input.lowStockThreshold, 'low_stock_threshold')); + const unitCost = + input.unitCost === undefined || input.unitCost === null + ? null + : normalizeUnitCostInBase(input.unitCost, input.unitCostUnit, baseUnit, 'unit_cost'); const id = input.id || newId(); const timestamp = now(); const isActive = input.isActive === false ? 0 : 1; withTxn(() => { db.prepare(` - INSERT INTO supplies (id, name, base_unit, stock_quantity, low_stock_threshold, is_active, created_at, updated_at) - VALUES (?, ?, ?, 0, ?, ?, ?, ?) - `).run(id, name, baseUnit, lowStockThreshold, isActive, timestamp, timestamp); + INSERT INTO supplies (id, name, base_unit, stock_quantity, low_stock_threshold, unit_cost, is_active, created_at, updated_at) + VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?) + `).run(id, name, baseUnit, lowStockThreshold, unitCost, isActive, timestamp, timestamp); if (stockQuantity !== 0) { if (!input.actorUserId) throw new SupplyServiceError(400, 'actor_user_id is required for opening stock'); + const fractionDigits = resolveDbCurrencyFractionDigits(db); + const openingTotalCost = + unitCost !== null && stockQuantity > 0 + ? roundCurrencyCost(new Decimal(stockQuantity).mul(unitCost), fractionDigits) + : null; applySupplyStockChange(db, { supplyId: id, quantityDelta: stockQuantity, movementType: 'adjustment', unit: baseUnit, + unitCost: unitCost !== null && stockQuantity > 0 ? unitCost : null, + totalCost: openingTotalCost, + unitCostAfter: unitCost, reason: 'Opening balance', actorUserId: input.actorUserId, referenceType: 'opening_balance', @@ -228,7 +305,13 @@ export function createSupply( export function updateSupply( db: ReturnType, id: string, - input: { name?: string; isActive?: boolean; lowStockThreshold?: number | null }, + input: { + name?: string; + isActive?: boolean; + lowStockThreshold?: number | null; + unitCost?: number | null; + unitCostUnit?: string; + }, ): SupplyRecord { const supply = getSupply(db, id); const name = input.name === undefined ? supply.name : String(input.name).trim(); @@ -253,11 +336,25 @@ export function updateSupply( lowStockThreshold = roundQuantity(threshold); } } + let unitCost: number | null = + supply.unit_cost === undefined || supply.unit_cost === null ? null : Number(supply.unit_cost); + if (input.unitCost !== undefined) { + if (input.unitCost === null) { + unitCost = null; + } else { + unitCost = normalizeUnitCostInBase( + input.unitCost, + input.unitCostUnit, + supply.base_unit, + 'unit_cost', + ); + } + } const isActive = input.isActive === undefined ? supply.is_active : input.isActive ? 1 : 0; db.prepare(` - UPDATE supplies SET name = ?, low_stock_threshold = ?, is_active = ?, updated_at = ? + UPDATE supplies SET name = ?, low_stock_threshold = ?, unit_cost = ?, is_active = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `).run(name, lowStockThreshold, isActive, now(), id); + `).run(name, lowStockThreshold, unitCost, isActive, now(), id); return getSupply(db, id); } @@ -279,6 +376,9 @@ export function applySupplyStockChange( quantityDelta: number; movementType: SupplyMovementType; unit?: string; + unitCost?: number | null; + totalCost?: number | null; + unitCostAfter?: number | null; reason?: string | null; actorUserId: string; referenceType?: string | null; @@ -306,19 +406,22 @@ export function applySupplyStockChange( const stockBefore = Number(supply.stock_quantity); const stockAfter = roundQuantity(stockBefore + deltaInBase); const timestamp = options.createdAt || now(); + const currentUnitCost = + supply.unit_cost === undefined || supply.unit_cost === null ? null : Number(supply.unit_cost); + const nextUnitCost = + options.unitCostAfter === undefined ? currentUnitCost : options.unitCostAfter; - db.prepare('UPDATE supplies SET stock_quantity = ?, updated_at = ? WHERE id = ?').run( - stockAfter, - timestamp, - options.supplyId, - ); + db.prepare( + 'UPDATE supplies SET stock_quantity = ?, unit_cost = ?, updated_at = ? WHERE id = ?', + ).run(stockAfter, nextUnitCost, timestamp, options.supplyId); const movement = db .prepare(` INSERT INTO supply_movements ( supply_id, quantity_delta, movement_type, unit, stock_after, + unit_cost, total_cost, unit_cost_after, reason, actor_user_id, reference_type, reference_id, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) .run( options.supplyId, @@ -326,6 +429,9 @@ export function applySupplyStockChange( options.movementType, supply.base_unit, stockAfter, + options.unitCost ?? null, + options.totalCost ?? null, + nextUnitCost, options.reason ?? null, options.actorUserId, options.referenceType ?? null, @@ -349,6 +455,16 @@ function recordSupplyMovementInTxn( const reason = assertReason(input); const timestamp = input.createdAt || now(); + const hasUnitCostInput = input.unitCost !== undefined && input.unitCost !== null; + const hasTotalCostInput = input.totalCost !== undefined && input.totalCost !== null; + if ((hasUnitCostInput || hasTotalCostInput) && input.movementType !== 'receive') { + throw new SupplyServiceError( + 400, + 'Purchase cost can only be recorded on receive movements', + 'cost_invalid', + ); + } + let delta: number; switch (input.movementType) { case 'receive': @@ -392,11 +508,49 @@ function recordSupplyMovementInTxn( ); } + const oldUnitCost = + supply.unit_cost === undefined || supply.unit_cost === null ? null : Number(supply.unit_cost); + let batchUnitCostInBase: number | null = null; + let batchTotalCost: number | null = null; + let nextUnitCost: number | null = oldUnitCost; + + if (input.movementType === 'receive' && (hasUnitCostInput || hasTotalCostInput)) { + const fractionDigits = resolveDbCurrencyFractionDigits(db); + if (hasUnitCostInput) { + const enteredUnitCost = normalizeCost(input.unitCost, 'unit_cost'); + const factorToBase = convertQuantity(1, unit, supply.base_unit); + batchUnitCostInBase = roundUnitCost(new Decimal(enteredUnitCost).div(factorToBase)); + batchTotalCost = hasTotalCostInput + ? roundCurrencyCost(normalizeCost(input.totalCost, 'total_cost'), fractionDigits) + : roundCurrencyCost(new Decimal(enteredUnitCost).mul(quantity), fractionDigits); + } else if (hasTotalCostInput) { + const enteredTotalCost = normalizeCost(input.totalCost, 'total_cost'); + batchTotalCost = roundCurrencyCost(enteredTotalCost, fractionDigits); + batchUnitCostInBase = roundUnitCost(new Decimal(enteredTotalCost).div(delta)); + } + + const stockBefore = Number(supply.stock_quantity); + if (batchUnitCostInBase !== null) { + if (oldUnitCost === null || stockBefore <= 0) { + nextUnitCost = batchUnitCostInBase; + } else { + const weightedTotal = new Decimal(stockBefore) + .mul(oldUnitCost) + .plus(new Decimal(delta).mul(batchUnitCostInBase)); + const newStock = new Decimal(stockBefore).plus(delta); + nextUnitCost = roundUnitCost(weightedTotal.div(newStock)); + } + } + } + const { movementId } = applySupplyStockChange(db, { supplyId: input.supplyId, quantityDelta: delta, movementType: input.movementType, unit: supply.base_unit, + unitCost: batchUnitCostInBase, + totalCost: batchTotalCost, + unitCostAfter: nextUnitCost, reason, actorUserId: input.actorUserId, referenceType: input.referenceType ?? null, @@ -408,7 +562,8 @@ function recordSupplyMovementInTxn( .prepare(` SELECT m.id, m.supply_id, s.name AS supply_name, m.quantity_delta, m.movement_type, m.unit, - m.stock_after, m.reason, m.actor_user_id, u.name AS actor_name, + m.stock_after, m.unit_cost, m.total_cost, m.unit_cost_after, + m.reason, m.actor_user_id, u.name AS actor_name, m.reference_type, m.reference_id, m.created_at FROM supply_movements m LEFT JOIN supplies s ON s.id = m.supply_id @@ -468,7 +623,8 @@ export function listSupplyMovements( .prepare(` SELECT m.id, m.supply_id, s.name AS supply_name, m.quantity_delta, m.movement_type, m.unit, - m.stock_after, m.reason, m.actor_user_id, u.name AS actor_name, + m.stock_after, m.unit_cost, m.total_cost, m.unit_cost_after, + m.reason, m.actor_user_id, u.name AS actor_name, m.reference_type, m.reference_id, m.created_at FROM supply_movements m LEFT JOIN supplies s ON s.id = m.supply_id diff --git a/package.json b/package.json index e6e2bdeeb..7cd929921 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "pretest": "npm run test:script-coverage && node scripts/run-test.cjs npm run test:payment-methods-split && node scripts/run-test.cjs npm run test:release-regressions && node scripts/run-test.cjs npm run test:product-variants-db && node scripts/run-test.cjs npm run test:product-variants-api", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", - "test": "node scripts/run-test.cjs npm run test:smoke && node scripts/run-test.cjs npm run test:server-port-collision && node scripts/run-test.cjs npm run test:kds-integration && node scripts/run-test.cjs npm run test:kds-broadcast-grouping && node scripts/run-test.cjs npm run test:kds-contract && node scripts/run-test.cjs npm run test:kds-frontend-conflict && node scripts/run-test.cjs npm run test:kds-window-hardening && node scripts/run-test.cjs npm run test:electron-api-contract && node scripts/run-test.cjs npm run test:titlebar-window-options && node scripts/run-test.cjs npm run test:update-ipc-sender-validation && node scripts/run-test.cjs npm run test:menu-surface && node scripts/run-test.cjs npm run test:pos-keyboard-shortcuts && node scripts/run-test.cjs npm run test:window-readiness && node scripts/run-test.cjs npm run test:window-load-retry && node scripts/run-test.cjs npm run test:cors && node scripts/run-test.cjs npm run test:csp-lan && node scripts/run-test.cjs npm run test:release-config && node scripts/run-test.cjs npm run test:update-state && node scripts/run-test.cjs npm run test:update-ui && node scripts/run-test.cjs npm run test:update-channel && node scripts/run-test.cjs npm run test:telemetry && node scripts/run-test.cjs npm run test:telemetry-install-provenance && node scripts/run-test.cjs npm run test:country-provenance && node scripts/run-test.cjs npm run test:first-run && node scripts/run-test.cjs npm run test:phase7-setup-i18n && node scripts/run-test.cjs npm run test:security && node scripts/run-test.cjs npm run test:jwt-secret-cache-identity && node scripts/run-test.cjs npm run test:jwt-secret-persist-before-cache && node scripts/run-test.cjs npm run test:jwt-secret-safe-storage && node scripts/run-test.cjs npm run test:staff-authz && node scripts/run-test.cjs npm run test:orders-authz && node scripts/run-test.cjs npm run test:order-item-cancel-restore && node scripts/run-test.cjs npm run test:order-item-edit && node scripts/run-test.cjs npm run test:item-price-override && node scripts/run-test.cjs npm run test:offline-mode && node scripts/run-test.cjs npm run test:product-variants-orders && node scripts/run-test.cjs npm run test:addon-inventory-pos-surface && node scripts/run-test.cjs npm run test:addon-inventory-orders && node scripts/run-test.cjs npm run test:addon-inventory-lifecycle && node scripts/run-test.cjs npm run test:order-totals-recompute && node scripts/run-test.cjs npm run test:issue-718-whatsapp-order-status && node scripts/run-test.cjs npm run test:order-audit-log && node scripts/run-test.cjs npm run test:authz-phase3 && node scripts/run-test.cjs npm run test:authorization-permissions && node scripts/run-test.cjs npm run test:auth-ui-deterministic && node scripts/run-test.cjs npm run test:desktop-trailing-slash-routing && node scripts/run-test.cjs npm run test:customer-auth && node scripts/run-test.cjs npm run test:customer-soft-reactivate-bindings && node scripts/run-test.cjs npm run test:customer-pagination && node scripts/run-test.cjs npm run test:customer-top-items && node scripts/run-test.cjs npm run test:server-app-server-role && node scripts/run-test.cjs npm run test:backup && node scripts/run-test.cjs npm run test:issue-278-fail-closed-db && node scripts/run-test.cjs npm run test:recovery-cloud && node scripts/run-test.cjs npm run test:cloud-account-status && node scripts/run-test.cjs npm run test:cloud-live-orders-batching && node scripts/run-test.cjs npm run test:printer && node scripts/run-test.cjs npm run test:printing-settings && node scripts/run-test.cjs npm run test:printer-width-refresh && node scripts/run-test.cjs npm run test:printer-migrations && node scripts/run-test.cjs npm run test:print-parity && node scripts/run-test.cjs npm run test:delivery-slip && node scripts/run-test.cjs npm run test:delivery-address && node scripts/run-test.cjs npm run test:thermal-capabilities && node scripts/run-test.cjs npm run test:raster && node scripts/run-test.cjs npm run test:merchant-print-templates && node scripts/run-test.cjs npm run test:merchant-template-transfer && node scripts/run-test.cjs npm run test:print-document && node scripts/run-test.cjs npm run test:menu-printing && node scripts/run-test.cjs npm run test:print-kernel && node scripts/run-test.cjs npm run test:translations && node scripts/run-test.cjs npm run test:print-labels && node scripts/run-test.cjs npm run test:locale-chunks && node scripts/run-test.cjs npm run test:rtl-foundation && node scripts/run-test.cjs npm run test:rtl-setup-auth-settings && node scripts/run-test.cjs npm run test:rtl-dashboard-pos-common && node scripts/run-test.cjs npm run test:rtl-kds-server-whatsapp && node scripts/run-test.cjs npm run test:phone && node scripts/run-test.cjs npm run test:country-localization && node scripts/run-test.cjs npm run test:e2e-argentina-flow && node scripts/run-test.cjs npm run test:charges && node scripts/run-test.cjs npm run test:charges-engine && node scripts/run-test.cjs npm run test:currency && node scripts/run-test.cjs npm run test:tax-engine && node scripts/run-test.cjs npm run test:tax-components && node scripts/run-test.cjs npm run test:tax-pack-catalog && node scripts/run-test.cjs npm run test:tax-pack-management && node scripts/run-test.cjs npm run test:manual-tax-config && node scripts/run-test.cjs npm run test:legacy-tax-pack-digest && node scripts/run-test.cjs npm run test:community-tax-packs && node scripts/run-test.cjs npm run test:support-ticket && node scripts/run-test.cjs npm run test:customer-phone-search && node scripts/run-test.cjs npm run test:phone-search-integration && node scripts/run-test.cjs npm run test:orders-search && node scripts/run-test.cjs npm run test:orders-layout-settings && node scripts/run-test.cjs npm run test:pos-catalog-sort && node scripts/run-test.cjs npm run test:pos-sort-settings && node scripts/run-test.cjs npm run test:receipt-column-width && node scripts/run-test.cjs npm run test:receipt-column-oracle && node scripts/run-test.cjs npm run test:notes-validation && node scripts/run-test.cjs npm run test:receipt-printing && node scripts/run-test.cjs npm run test:cancel-override && node scripts/run-test.cjs npm run test:refunds && node scripts/run-test.cjs npm run test:async-refund-pin-event-loop && node scripts/run-test.cjs npm run test:refund-completed-orders && node scripts/run-test.cjs npm run test:cash-closures && node scripts/run-test.cjs npm run test:cash-sessions && node scripts/run-test.cjs npm run test:kitchen-addons && node scripts/run-test.cjs npm run test:kot-order-notes && node scripts/run-test.cjs npm run test:pos-info-network-ranking && node scripts/run-test.cjs npm run test:order-item-addons && node scripts/run-test.cjs npm run test:issue-125-addon-reads && node scripts/run-test.cjs npm run test:windows-country-code-crash && node scripts/run-test.cjs npm run test:reports-insights && node scripts/run-test.cjs npm run test:reports-daily-stats-table-turn && node scripts/run-test.cjs npm run test:timezone-report-boundaries && node scripts/run-test.cjs npm run test:business-date && node scripts/run-test.cjs npm run test:dashboard-business-date && node scripts/run-test.cjs npm run test:sequence && node scripts/run-test.cjs npm run test:order-numbering-settings && node scripts/run-test.cjs npm run test:integration-happy && node scripts/run-test.cjs npm run test:integration-order-hold-payment && node scripts/run-test.cjs npm run test:integration-tax && node scripts/run-test.cjs npm run test:integration-payments && node scripts/run-test.cjs npm run test:kitchen-delivery-enforcement && node scripts/run-test.cjs npm run test:issue-214 && node scripts/run-test.cjs npm run test:issue-214-auth && node scripts/run-test.cjs npm run test:issue-214-migration && node scripts/run-test.cjs npm run test:issue-255 && node scripts/run-test.cjs npm run test:cart-variant-identity && node scripts/run-test.cjs npm run test:cart-price-override && node scripts/run-test.cjs npm run test:product-variants-screen && node scripts/run-test.cjs npm run test:variant-pos-surface && node scripts/run-test.cjs npm run test:issue-788-order-attempt-storage && node scripts/run-test.cjs npm run test:integration-lifecycle && node scripts/run-test.cjs npm run test:integration-reconciliation && node scripts/run-test.cjs npm run test:integration-loyalty && node scripts/run-test.cjs npm run test:integration-discount && node scripts/run-test.cjs npm run test:loyalty-toggle && node scripts/run-test.cjs npm run test:discount-system && node scripts/run-test.cjs npm run test:integration-discount-settings && node scripts/run-test.cjs npm run test:integration-loyalty-global && node scripts/run-test.cjs npm run test:issue-248-csv && node scripts/run-test.cjs npm run test:integration-loyalty-redemption && node scripts/run-test.cjs npm run test:bills-print-api && node scripts/run-test.cjs npm run test:issue-24 && node scripts/run-test.cjs npm run test:issue-134-routing && node scripts/run-test.cjs npm run test:issue-134-mgmt && node scripts/run-test.cjs npm run test:issue-137-barcode && node scripts/run-test.cjs npm run test:issue-244-product-addon-links && node scripts/run-test.cjs npm run test:issue-245-246-addon-groups && node scripts/run-test.cjs npm run test:issue-247-catalog-invariants && node scripts/run-test.cjs npm run test:issue-250-catalog-perf && node scripts/run-test.cjs npm run test:issue-252 && node scripts/run-test.cjs npm run test:inventory-ledger && node scripts/run-test.cjs npm run test:issue-printing-and-kds-resilience && node scripts/run-test.cjs npm run test:issue-258-bill-pagination && node scripts/run-test.cjs npm run test:issue-265-morocco-profile && node scripts/run-test.cjs npm run test:issue-266-currency-symbol-print && node scripts/run-test.cjs npm run test:tables-string-ids && node scripts/run-test.cjs npm run test:issue-646-floor-management && node scripts/run-test.cjs npm run test:held-orders && node scripts/run-test.cjs npm run test:product-images && node scripts/run-test.cjs npm run test:schema-health && node scripts/run-test.cjs npm run test:migration-registry && node scripts/run-test.cjs npm run test:upgrade-path && node scripts/run-test.cjs npm run test:upgrade-matrix-harness && node scripts/run-test.cjs npm run test:migration-v56-v57 && node scripts/run-test.cjs npm run test:migration-v71-repair && node scripts/run-test.cjs npm run test:migration-v80-cash-drawer-pulse && node scripts/run-test.cjs npm run test:migration-v81-cash-closures && node scripts/run-test.cjs npm run test:master-pin && node scripts/run-test.cjs npm run test:google-drive && node scripts/run-test.cjs npm run test:database-tools-api && node scripts/run-test.cjs npm run test:phone-validation && node scripts/run-test.cjs npm run test:phone-migration && node scripts/run-test.cjs npm run test:issue-133-kds-kot-toggles && node scripts/run-test.cjs npm run test:whatsapp-schema && node scripts/run-test.cjs npm run test:whatsapp-service && node scripts/run-test.cjs npm run test:whatsapp-middleware && node scripts/run-test.cjs npm run test:issue-127-password-recovery && node scripts/run-test.cjs npm run test:dev-tooling && node scripts/run-test.cjs npm run test:windows-uninstaller && node scripts/run-test.cjs npm run test:shutdown-lifecycle && node scripts/run-test.cjs npm run test:redos-hardening && node scripts/run-test.cjs npm run test:startup-cache && node scripts/run-test.cjs npm run test:service-worker && node scripts/run-test.cjs npm run test:issue-389-timezone-override && node scripts/run-test.cjs npm run test:issue-390-locale-preference-invariants && node scripts/run-test.cjs npm run test:issue-475-picker-highlight && node scripts/run-test.cjs npm run test:theme-mode-settings && node scripts/run-test.cjs npm run test:theme-fouc-script && node scripts/run-test.cjs npm run test:ui-regressions-621-623-626 && node scripts/run-test.cjs npm run test:issue-zero-balance-settlement && node scripts/run-test.cjs npm run test:daily-sales-export && node scripts/run-test.cjs npm run test:card-transaction-id && node scripts/run-test.cjs npm run test:issue-terminal-state-guards && node scripts/run-test.cjs npm run test:issue-exact-balance-settlement && node scripts/run-test.cjs npm run test:financial-safety-remediation && node scripts/run-test.cjs npm run test:issue-payload-and-discount-bounds && node scripts/run-test.cjs npm run test:customer-display-config && node scripts/run-test.cjs npm run test:customer-display-settings-route && node scripts/run-test.cjs npm run test:customer-display-pairing && node scripts/run-test.cjs npm run test:customer-display-feed && node scripts/run-test.cjs npm run test:units && node scripts/run-test.cjs npm run test:supplies-service && node scripts/run-test.cjs npm run test:recipe-order-lifecycle && node scripts/run-test.cjs npm run test:lint-engine && node scripts/run-test.cjs npm run test:oxc-contributor-checks && node scripts/run-test.cjs npm run test:expenses && node scripts/run-test.cjs npm run test:diagnostics && node scripts/run-test.cjs npm run test:reports-export && node scripts/run-test.cjs npm run test:category-addon-groups && node scripts/run-test.cjs npm run test:addon-inventory-db && node scripts/run-test.cjs npm run test:addon-inventory-api && node scripts/run-test.cjs npm run test:discount-kitchen-approver-identity && node scripts/run-test.cjs npm run test:staff-approvers-directory && node scripts/run-test.cjs npm run test:local-backup && npm run test:password-policy && npm run test:cloud-password-recovery", + "test": "node scripts/run-test.cjs npm run test:smoke && node scripts/run-test.cjs npm run test:server-port-collision && node scripts/run-test.cjs npm run test:kds-integration && node scripts/run-test.cjs npm run test:kds-broadcast-grouping && node scripts/run-test.cjs npm run test:kds-contract && node scripts/run-test.cjs npm run test:kds-frontend-conflict && node scripts/run-test.cjs npm run test:kds-window-hardening && node scripts/run-test.cjs npm run test:electron-api-contract && node scripts/run-test.cjs npm run test:titlebar-window-options && node scripts/run-test.cjs npm run test:update-ipc-sender-validation && node scripts/run-test.cjs npm run test:menu-surface && node scripts/run-test.cjs npm run test:pos-keyboard-shortcuts && node scripts/run-test.cjs npm run test:window-readiness && node scripts/run-test.cjs npm run test:window-load-retry && node scripts/run-test.cjs npm run test:cors && node scripts/run-test.cjs npm run test:csp-lan && node scripts/run-test.cjs npm run test:release-config && node scripts/run-test.cjs npm run test:update-state && node scripts/run-test.cjs npm run test:update-ui && node scripts/run-test.cjs npm run test:update-channel && node scripts/run-test.cjs npm run test:telemetry && node scripts/run-test.cjs npm run test:telemetry-install-provenance && node scripts/run-test.cjs npm run test:country-provenance && node scripts/run-test.cjs npm run test:first-run && node scripts/run-test.cjs npm run test:phase7-setup-i18n && node scripts/run-test.cjs npm run test:security && node scripts/run-test.cjs npm run test:jwt-secret-cache-identity && node scripts/run-test.cjs npm run test:jwt-secret-persist-before-cache && node scripts/run-test.cjs npm run test:jwt-secret-safe-storage && node scripts/run-test.cjs npm run test:staff-authz && node scripts/run-test.cjs npm run test:orders-authz && node scripts/run-test.cjs npm run test:order-item-cancel-restore && node scripts/run-test.cjs npm run test:order-item-edit && node scripts/run-test.cjs npm run test:item-price-override && node scripts/run-test.cjs npm run test:offline-mode && node scripts/run-test.cjs npm run test:product-variants-orders && node scripts/run-test.cjs npm run test:addon-inventory-pos-surface && node scripts/run-test.cjs npm run test:addon-inventory-orders && node scripts/run-test.cjs npm run test:addon-inventory-lifecycle && node scripts/run-test.cjs npm run test:order-totals-recompute && node scripts/run-test.cjs npm run test:issue-718-whatsapp-order-status && node scripts/run-test.cjs npm run test:order-audit-log && node scripts/run-test.cjs npm run test:authz-phase3 && node scripts/run-test.cjs npm run test:authorization-permissions && node scripts/run-test.cjs npm run test:auth-ui-deterministic && node scripts/run-test.cjs npm run test:desktop-trailing-slash-routing && node scripts/run-test.cjs npm run test:customer-auth && node scripts/run-test.cjs npm run test:customer-soft-reactivate-bindings && node scripts/run-test.cjs npm run test:customer-pagination && node scripts/run-test.cjs npm run test:customer-top-items && node scripts/run-test.cjs npm run test:server-app-server-role && node scripts/run-test.cjs npm run test:backup && node scripts/run-test.cjs npm run test:issue-278-fail-closed-db && node scripts/run-test.cjs npm run test:recovery-cloud && node scripts/run-test.cjs npm run test:cloud-account-status && node scripts/run-test.cjs npm run test:cloud-live-orders-batching && node scripts/run-test.cjs npm run test:printer && node scripts/run-test.cjs npm run test:printing-settings && node scripts/run-test.cjs npm run test:printer-width-refresh && node scripts/run-test.cjs npm run test:printer-migrations && node scripts/run-test.cjs npm run test:print-parity && node scripts/run-test.cjs npm run test:delivery-slip && node scripts/run-test.cjs npm run test:delivery-address && node scripts/run-test.cjs npm run test:thermal-capabilities && node scripts/run-test.cjs npm run test:raster && node scripts/run-test.cjs npm run test:merchant-print-templates && node scripts/run-test.cjs npm run test:merchant-template-transfer && node scripts/run-test.cjs npm run test:print-document && node scripts/run-test.cjs npm run test:menu-printing && node scripts/run-test.cjs npm run test:print-kernel && node scripts/run-test.cjs npm run test:translations && node scripts/run-test.cjs npm run test:print-labels && node scripts/run-test.cjs npm run test:locale-chunks && node scripts/run-test.cjs npm run test:rtl-foundation && node scripts/run-test.cjs npm run test:rtl-setup-auth-settings && node scripts/run-test.cjs npm run test:rtl-dashboard-pos-common && node scripts/run-test.cjs npm run test:rtl-kds-server-whatsapp && node scripts/run-test.cjs npm run test:phone && node scripts/run-test.cjs npm run test:country-localization && node scripts/run-test.cjs npm run test:e2e-argentina-flow && node scripts/run-test.cjs npm run test:charges && node scripts/run-test.cjs npm run test:charges-engine && node scripts/run-test.cjs npm run test:currency && node scripts/run-test.cjs npm run test:tax-engine && node scripts/run-test.cjs npm run test:tax-components && node scripts/run-test.cjs npm run test:tax-pack-catalog && node scripts/run-test.cjs npm run test:tax-pack-management && node scripts/run-test.cjs npm run test:manual-tax-config && node scripts/run-test.cjs npm run test:legacy-tax-pack-digest && node scripts/run-test.cjs npm run test:community-tax-packs && node scripts/run-test.cjs npm run test:support-ticket && node scripts/run-test.cjs npm run test:customer-phone-search && node scripts/run-test.cjs npm run test:phone-search-integration && node scripts/run-test.cjs npm run test:orders-search && node scripts/run-test.cjs npm run test:orders-layout-settings && node scripts/run-test.cjs npm run test:pos-catalog-sort && node scripts/run-test.cjs npm run test:pos-sort-settings && node scripts/run-test.cjs npm run test:receipt-column-width && node scripts/run-test.cjs npm run test:receipt-column-oracle && node scripts/run-test.cjs npm run test:notes-validation && node scripts/run-test.cjs npm run test:receipt-printing && node scripts/run-test.cjs npm run test:cancel-override && node scripts/run-test.cjs npm run test:refunds && node scripts/run-test.cjs npm run test:async-refund-pin-event-loop && node scripts/run-test.cjs npm run test:refund-completed-orders && node scripts/run-test.cjs npm run test:cash-closures && node scripts/run-test.cjs npm run test:cash-sessions && node scripts/run-test.cjs npm run test:kitchen-addons && node scripts/run-test.cjs npm run test:kot-order-notes && node scripts/run-test.cjs npm run test:pos-info-network-ranking && node scripts/run-test.cjs npm run test:order-item-addons && node scripts/run-test.cjs npm run test:issue-125-addon-reads && node scripts/run-test.cjs npm run test:windows-country-code-crash && node scripts/run-test.cjs npm run test:reports-insights && node scripts/run-test.cjs npm run test:reports-daily-stats-table-turn && node scripts/run-test.cjs npm run test:timezone-report-boundaries && node scripts/run-test.cjs npm run test:business-date && node scripts/run-test.cjs npm run test:dashboard-business-date && node scripts/run-test.cjs npm run test:sequence && node scripts/run-test.cjs npm run test:order-numbering-settings && node scripts/run-test.cjs npm run test:integration-happy && node scripts/run-test.cjs npm run test:integration-order-hold-payment && node scripts/run-test.cjs npm run test:integration-tax && node scripts/run-test.cjs npm run test:integration-payments && node scripts/run-test.cjs npm run test:kitchen-delivery-enforcement && node scripts/run-test.cjs npm run test:issue-214 && node scripts/run-test.cjs npm run test:issue-214-auth && node scripts/run-test.cjs npm run test:issue-214-migration && node scripts/run-test.cjs npm run test:issue-255 && node scripts/run-test.cjs npm run test:cart-variant-identity && node scripts/run-test.cjs npm run test:cart-price-override && node scripts/run-test.cjs npm run test:product-variants-screen && node scripts/run-test.cjs npm run test:variant-pos-surface && node scripts/run-test.cjs npm run test:issue-788-order-attempt-storage && node scripts/run-test.cjs npm run test:integration-lifecycle && node scripts/run-test.cjs npm run test:integration-reconciliation && node scripts/run-test.cjs npm run test:integration-loyalty && node scripts/run-test.cjs npm run test:integration-discount && node scripts/run-test.cjs npm run test:loyalty-toggle && node scripts/run-test.cjs npm run test:discount-system && node scripts/run-test.cjs npm run test:integration-discount-settings && node scripts/run-test.cjs npm run test:integration-loyalty-global && node scripts/run-test.cjs npm run test:issue-248-csv && node scripts/run-test.cjs npm run test:integration-loyalty-redemption && node scripts/run-test.cjs npm run test:bills-print-api && node scripts/run-test.cjs npm run test:issue-24 && node scripts/run-test.cjs npm run test:issue-134-routing && node scripts/run-test.cjs npm run test:issue-134-mgmt && node scripts/run-test.cjs npm run test:issue-137-barcode && node scripts/run-test.cjs npm run test:issue-244-product-addon-links && node scripts/run-test.cjs npm run test:issue-245-246-addon-groups && node scripts/run-test.cjs npm run test:issue-247-catalog-invariants && node scripts/run-test.cjs npm run test:issue-250-catalog-perf && node scripts/run-test.cjs npm run test:issue-252 && node scripts/run-test.cjs npm run test:inventory-ledger && node scripts/run-test.cjs npm run test:issue-printing-and-kds-resilience && node scripts/run-test.cjs npm run test:issue-258-bill-pagination && node scripts/run-test.cjs npm run test:issue-265-morocco-profile && node scripts/run-test.cjs npm run test:issue-266-currency-symbol-print && node scripts/run-test.cjs npm run test:tables-string-ids && node scripts/run-test.cjs npm run test:issue-646-floor-management && node scripts/run-test.cjs npm run test:held-orders && node scripts/run-test.cjs npm run test:product-images && node scripts/run-test.cjs npm run test:schema-health && node scripts/run-test.cjs npm run test:migration-registry && node scripts/run-test.cjs npm run test:upgrade-path && node scripts/run-test.cjs npm run test:upgrade-matrix-harness && node scripts/run-test.cjs npm run test:migration-v56-v57 && node scripts/run-test.cjs npm run test:migration-v71-repair && node scripts/run-test.cjs npm run test:migration-v80-cash-drawer-pulse && node scripts/run-test.cjs npm run test:migration-v81-cash-closures && node scripts/run-test.cjs npm run test:master-pin && node scripts/run-test.cjs npm run test:google-drive && node scripts/run-test.cjs npm run test:database-tools-api && node scripts/run-test.cjs npm run test:phone-validation && node scripts/run-test.cjs npm run test:phone-migration && node scripts/run-test.cjs npm run test:issue-133-kds-kot-toggles && node scripts/run-test.cjs npm run test:whatsapp-schema && node scripts/run-test.cjs npm run test:whatsapp-service && node scripts/run-test.cjs npm run test:whatsapp-middleware && node scripts/run-test.cjs npm run test:issue-127-password-recovery && node scripts/run-test.cjs npm run test:dev-tooling && node scripts/run-test.cjs npm run test:windows-uninstaller && node scripts/run-test.cjs npm run test:shutdown-lifecycle && node scripts/run-test.cjs npm run test:redos-hardening && node scripts/run-test.cjs npm run test:startup-cache && node scripts/run-test.cjs npm run test:service-worker && node scripts/run-test.cjs npm run test:issue-389-timezone-override && node scripts/run-test.cjs npm run test:issue-390-locale-preference-invariants && node scripts/run-test.cjs npm run test:issue-475-picker-highlight && node scripts/run-test.cjs npm run test:theme-mode-settings && node scripts/run-test.cjs npm run test:theme-fouc-script && node scripts/run-test.cjs npm run test:ui-regressions-621-623-626 && node scripts/run-test.cjs npm run test:issue-zero-balance-settlement && node scripts/run-test.cjs npm run test:daily-sales-export && node scripts/run-test.cjs npm run test:card-transaction-id && node scripts/run-test.cjs npm run test:issue-terminal-state-guards && node scripts/run-test.cjs npm run test:issue-exact-balance-settlement && node scripts/run-test.cjs npm run test:financial-safety-remediation && node scripts/run-test.cjs npm run test:issue-payload-and-discount-bounds && node scripts/run-test.cjs npm run test:customer-display-config && node scripts/run-test.cjs npm run test:customer-display-settings-route && node scripts/run-test.cjs npm run test:customer-display-pairing && node scripts/run-test.cjs npm run test:customer-display-feed && node scripts/run-test.cjs npm run test:units && node scripts/run-test.cjs npm run test:supplies-service && node scripts/run-test.cjs npm run test:recipe-order-lifecycle && node scripts/run-test.cjs npm run test:ingredient-cost && node scripts/run-test.cjs npm run test:lint-engine && node scripts/run-test.cjs npm run test:oxc-contributor-checks && node scripts/run-test.cjs npm run test:expenses && node scripts/run-test.cjs npm run test:diagnostics && node scripts/run-test.cjs npm run test:reports-export && node scripts/run-test.cjs npm run test:category-addon-groups && node scripts/run-test.cjs npm run test:addon-inventory-db && node scripts/run-test.cjs npm run test:addon-inventory-api && node scripts/run-test.cjs npm run test:discount-kitchen-approver-identity && node scripts/run-test.cjs npm run test:staff-approvers-directory && node scripts/run-test.cjs npm run test:local-backup && npm run test:password-policy && npm run test:cloud-password-recovery", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", "test:script-coverage": "node scripts/ci/validate-test-script-coverage.cjs && node tests/test-script-coverage.test.cjs && ts-node --transpile-only -P tests/tsconfig.json tests/test-harness-assertions.test.ts", "test:lint-engine": "ts-node --transpile-only -P tests/tsconfig.json tests/lint-engine-parity.test.ts", @@ -311,6 +311,7 @@ "test:supplies-service": "node tests/run-electron-node-test.cjs tests/supplies-service.test.ts", "test:expenses": "ts-node --transpile-only -P tests/tsconfig.json tests/expense-attempt.test.ts && node tests/run-electron-node-test.cjs tests/expense-records.test.ts && node tests/run-electron-node-test.cjs tests/expense-api.test.ts && node tests/run-electron-node-test.cjs tests/expense-payments.test.ts", "test:recipe-order-lifecycle": "node tests/run-electron-node-test.cjs tests/recipe-order-lifecycle.test.ts", + "test:ingredient-cost": "node tests/run-electron-node-test.cjs tests/ingredient-cost-lifecycle.test.ts", "test:receipt-column-oracle": "ts-node --transpile-only -P tests/tsconfig.json tests/receipt-column-oracle.test.ts", "test:jwt-secret-cache-identity": "node tests/run-electron-node-test.cjs tests/jwt-secret-cache-identity.test.ts", "test:jwt-secret-safe-storage": "node tests/run-electron-node-test.cjs tests/jwt-secret-safe-storage.test.ts", diff --git a/tests/backup-restore-production.test.ts b/tests/backup-restore-production.test.ts index 81292da7d..1bcdb62d1 100644 --- a/tests/backup-restore-production.test.ts +++ b/tests/backup-restore-production.test.ts @@ -973,8 +973,8 @@ async function run() { initDatabase(); assert.equal( getCurrentSchemaVersion(), - 116, - 'interrupted v114 replacement recovered and migrated to v116', + 117, + 'interrupted v114 replacement recovered and migrated to v117', ); assert.equal( fs.existsSync(journalPath), diff --git a/tests/ingredient-cost-lifecycle.test.ts b/tests/ingredient-cost-lifecycle.test.ts new file mode 100644 index 000000000..fe371523d --- /dev/null +++ b/tests/ingredient-cost-lifecycle.test.ts @@ -0,0 +1,625 @@ +/** + * Ingredient cost tracking (#1283): migration v117, WAC + negative-stock reset, + * unit normalization, 0/2/3-decimal currencies, recipe cost derivation, and + * frozen order-item cost immutability across order lifecycle. + * + * Usage: node tests/run-electron-node-test.cjs tests/ingredient-cost-lifecycle.test.ts + */ +const Module = require('module'); +const originalLoad = Module._load; +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-ingredient-cost-')); +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + } + return originalLoad.apply(this, arguments as any); +}; + +const { + initTestDb, + createApp, + startServer, + seedOwnerUser, + seedManagerUser, + seedCategory, + seedProduct, + seedTable, + api, + assertOrThrow, + assertEqualOrThrow, + getResults, + resetCounters, + closeDatabase, +} = require('./helpers/test-setup'); +const { MIGRATIONS, getCurrentSchemaVersion } = require('../main/db'); +const { registerRoutes } = require('../main/routes/index'); +const { + createSupply, + getSupply, + recordSupplyMovement, + updateSupply, +} = require('../main/services/supplies'); +const { buildRecipeSnapshot, getRecipeByProduct, saveRecipe } = require('../main/services/recipes'); + +const PRISTINE_MIGRATIONS = MIGRATIONS.slice(); + +function expectServiceError(fn: () => void, statusCode: number, label: string): void { + try { + fn(); + assertOrThrow(false, `${label} (expected statusCode ${statusCode})`); + } catch (error: any) { + assertEqualOrThrow(error?.statusCode, statusCode, label); + } +} + +async function main() { + console.log('Ingredient cost lifecycle test'); + console.log('='.repeat(65)); + resetCounters(); + + try { + // ── 1. Migration v116 -> v117 upgrade path & fresh schema ── + console.log('\n--- 1. Upgrade migration v116 -> v117 ---'); + MIGRATIONS.length = 0; + MIGRATIONS.push(...PRISTINE_MIGRATIONS.filter((m: any) => m.version <= 116)); + + const db = initTestDb(); + assertEqualOrThrow(getCurrentSchemaVersion(), 116, 'fixture starts at schema v116'); + const { authHeader, userId } = seedOwnerUser(db); + seedManagerUser(db); + seedCategory(db, 'cat-cost', 'Coffee & Bakery'); + seedProduct(db, 'prod-latte', 'cat-cost', 'Latte', 5.0, { + track_inventory: false, + stock_quantity: 0, + cost: 1.25, // legacy manual products.cost must be ignored by recipe costing + }); + seedProduct(db, 'prod-muffin', 'cat-cost', 'Muffin (No Recipe)', 3.5, { + track_inventory: false, + stock_quantity: 0, + cost: 0.9, + }); + seedTable(db, 'tbl-cost-1', 1, 4); + + // Insert pre-v117 supply, movement, and order_item before running v117 + db.prepare(` + INSERT INTO supplies (id, name, base_unit, stock_quantity, low_stock_threshold, is_active, created_at, updated_at) + VALUES ('sup_legacy', 'Legacy Sugar', 'g', 500, 50, 1, '2026-01-01 00:00:00', '2026-01-01 00:00:00') + `).run(); + db.prepare(` + INSERT INTO supply_movements (supply_id, quantity_delta, movement_type, unit, stock_after, reason, actor_user_id, created_at) + VALUES ('sup_legacy', 500, 'receive', 'g', 500, 'Pre-v117 receive', ?, '2026-01-01 00:00:00') + `).run(userId); + db.prepare(` + INSERT INTO orders (id, order_number, user_id, type, status, subtotal, total, created_at, updated_at) + VALUES (1, 'ORD-LEGACY-1', ?, 'takeaway', 'completed', 5.0, 5.0, '2026-01-01 00:00:00', '2026-01-01 00:00:00') + `).run(userId); + db.prepare(` + INSERT INTO order_items (id, order_id, product_id, product_name, unit_price, quantity, subtotal, total, status, created_at, updated_at) + VALUES (1, 1, 'prod-latte', 'Latte', 5.0, 1, 5.0, 5.0, 'completed', '2026-01-01 00:00:00', '2026-01-01 00:00:00') + `).run(); + + // Now restore full MIGRATIONS and apply v117 + MIGRATIONS.length = 0; + MIGRATIONS.push(...PRISTINE_MIGRATIONS); + const v117 = MIGRATIONS.find((m: any) => m.version === 117); + assertOrThrow(Boolean(v117), 'migration v117 exists in registry'); + db.transaction(() => { + v117.up(); + db.pragma('user_version = 117'); + })(); + assertEqualOrThrow(getCurrentSchemaVersion(), 117, 'database upgraded to v117'); + + const legacySupply = getSupply(db, 'sup_legacy'); + assertEqualOrThrow(legacySupply.unit_cost, null, 'upgraded supply has unit_cost = null'); + const legacyMovement = db + .prepare( + "SELECT unit_cost, total_cost, unit_cost_after FROM supply_movements WHERE supply_id = 'sup_legacy'", + ) + .get(); + assertEqualOrThrow(legacyMovement.unit_cost, null, 'upgraded movement has unit_cost = null'); + assertEqualOrThrow(legacyMovement.total_cost, null, 'upgraded movement has total_cost = null'); + assertEqualOrThrow( + legacyMovement.unit_cost_after, + null, + 'upgraded movement has unit_cost_after = null', + ); + const legacyOrderItem = db + .prepare('SELECT ingredient_cost, ingredient_cost_known FROM order_items WHERE id = 1') + .get(); + assertEqualOrThrow( + legacyOrderItem.ingredient_cost, + null, + 'upgraded order_item has ingredient_cost = null', + ); + assertEqualOrThrow( + legacyOrderItem.ingredient_cost_known, + 0, + 'upgraded order_item has ingredient_cost_known = 0', + ); + + // ── 2. Unit normalization & Weighted Average Cost (WAC) + negative stock reset ── + console.log('\n--- 2. Unit normalization & WAC + negative stock reset ---'); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('currency', 'USD')").run(); + + const flour = createSupply(db, { + name: 'Bread Flour', + baseUnit: 'g', + stockQuantity: 0, + actorUserId: userId, + }); + assertEqualOrThrow(flour.unit_cost, null, 'new supply without cost has unit_cost = null'); + + // Receive 5 kg (5000 g) at $10.00 total -> $0.002 / g + const recv1 = recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'receive', + quantity: 5, + unit: 'kg', + totalCost: 10, + actorUserId: userId, + reason: 'Bag 1', + }); + assertEqualOrThrow(recv1.quantity_delta, 5000, 'receive converts 5 kg to 5000 g'); + assertEqualOrThrow(recv1.unit_cost, 0.002, 'movement stores batch unit_cost per base_unit (g)'); + assertEqualOrThrow(recv1.total_cost, 10, 'movement stores batch total_cost'); + assertEqualOrThrow(recv1.unit_cost_after, 0.002, 'movement stores unit_cost_after'); + assertEqualOrThrow( + getSupply(db, flour.id).unit_cost, + 0.002, + 'supply unit_cost set to 0.002 / g', + ); + + // Receive 5 kg (5000 g) at $4.00 per kg ($0.004 / g) -> WAC = (5000*0.002 + 5000*0.004)/10000 = 0.003 / g + const recv2 = recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'receive', + quantity: 5, + unit: 'kg', + unitCost: 4, + actorUserId: userId, + reason: 'Bag 2', + }); + assertEqualOrThrow(recv2.unit_cost, 0.004, 'second receive normalizes $4/kg to $0.004/g'); + assertEqualOrThrow(recv2.total_cost, 20, 'second receive derives total_cost = $20'); + assertEqualOrThrow( + recv2.unit_cost_after, + 0.003, + 'WAC blends 5000g @ 0.002 and 5000g @ 0.004 -> 0.003/g', + ); + assertEqualOrThrow( + getSupply(db, flour.id).unit_cost, + 0.003, + 'supply stores blended WAC 0.003/g', + ); + + // Receive 2 kg without entering cost -> preserves existing WAC (0.003/g) + const recvNoCost = recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'receive', + quantity: 2, + unit: 'kg', + actorUserId: userId, + reason: 'Unpriced delivery', + }); + assertEqualOrThrow( + recvNoCost.unit_cost, + null, + 'unpriced receive has movement unit_cost = null', + ); + assertEqualOrThrow( + recvNoCost.total_cost, + null, + 'unpriced receive has movement total_cost = null', + ); + assertEqualOrThrow( + recvNoCost.unit_cost_after, + 0.003, + 'unpriced receive preserves unit_cost_after = 0.003', + ); + assertEqualOrThrow( + getSupply(db, flour.id).unit_cost, + 0.003, + 'unpriced receive does not wipe supply unit_cost', + ); + + // Waste down to negative stock (-500 g), then receive exactly 500 g (stockBefore = -500, stockAfter = 0) + recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'waste', + quantity: 12.5, + unit: 'kg', + actorUserId: userId, + reason: 'Empty + rush negative stock', + }); + assertEqualOrThrow(getSupply(db, flour.id).stock_quantity, -500, 'flour stock is -500 g'); + + const recvFromNegativeToZero = recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'receive', + quantity: 500, + unit: 'g', + unitCost: 0.005, + actorUserId: userId, + reason: 'Replenish to zero', + }); + assertEqualOrThrow(recvFromNegativeToZero.stock_after, 0, 'stock reaches 0 g'); + assertEqualOrThrow( + getSupply(db, flour.id).unit_cost, + 0.005, + 'receive when stockBefore < 0 resets unit_cost to batch cost without division by zero', + ); + + // Receive while negative but ending positive (-200 g + 1000 g = +800 g) + recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'waste', + quantity: 200, + unit: 'g', + actorUserId: userId, + reason: 'Rush negative', + }); + assertEqualOrThrow(getSupply(db, flour.id).stock_quantity, -200, 'flour stock is -200 g'); + recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'receive', + quantity: 1, + unit: 'kg', + unitCost: 6, // $6/kg = $0.006/g + actorUserId: userId, + }); + assertEqualOrThrow( + getSupply(db, flour.id).unit_cost, + 0.006, + 'receive from -200 g to +800 g resets unit_cost to $0.006/g instead of weighting negative stock', + ); + + // Validation errors on cost + expectServiceError( + () => + recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'receive', + quantity: 100, + unit: 'g', + unitCost: -1, + actorUserId: userId, + }), + 400, + 'rejects negative unit_cost on receive', + ); + expectServiceError( + () => + recordSupplyMovement(db, { + supplyId: flour.id, + movementType: 'waste', + quantity: 100, + unit: 'g', + unitCost: 0.01, + actorUserId: userId, + reason: 'Bad cost on waste', + }), + 400, + 'rejects unit_cost on non-receive movement', + ); + + // ── 3. Currency precision (0-decimal JPY, 2-decimal USD, 3-decimal KWD) ── + console.log('\n--- 3. Sub-minor-unit supply cost & 0/2/3-decimal currency rounding ---'); + const matcha = createSupply(db, { + name: 'Matcha Powder', + baseUnit: 'g', + stockQuantity: 1000, + unitCost: 250, // 250 per kg = 0.25 per g + unitCostUnit: 'kg', + actorUserId: userId, + }); + assertEqualOrThrow(matcha.unit_cost, 0.25, 'createSupply normalizes 250/kg to 0.25/g'); + + seedProduct(db, 'prod-matcha', 'cat-cost', 'Matcha Shot', 500, { + track_inventory: false, + stock_quantity: 0, + }); + saveRecipe(db, { + productId: 'prod-matcha', + yieldQuantity: 1, + items: [{ supplyId: matcha.id, quantity: 15, unit: 'g' }], // 15 * 0.25 = 3.75 + }); + + // 0-decimal currency (JPY): 3.75 rounds to 4 + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('currency', 'JPY')").run(); + const snapJpy = buildRecipeSnapshot(db, 'prod-matcha', 1); + assertEqualOrThrow(snapJpy.cost_known, true, 'JPY snapshot cost is known'); + assertEqualOrThrow( + snapJpy.components[0].unit_cost, + 0.25, + 'JPY snapshot preserves sub-yen 0.25/g component unit_cost', + ); + assertEqualOrThrow( + snapJpy.total_ingredient_cost, + 4, + 'JPY (0 decimals) rounds 3.75 total_ingredient_cost to 4', + ); + + // 3-decimal currency (KWD): 0.0015 KWD/g * 15 g = 0.0225 KWD -> rounds to 0.023 KWD + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('currency', 'KWD')").run(); + updateSupply(db, matcha.id, { unitCost: 1.5, unitCostUnit: 'kg' }); // 0.0015 KWD/g + const snapKwd = buildRecipeSnapshot(db, 'prod-matcha', 1); + assertEqualOrThrow( + snapKwd.components[0].unit_cost, + 0.0015, + 'KWD snapshot preserves sub-fils 0.0015/g component unit_cost', + ); + assertEqualOrThrow( + snapKwd.total_ingredient_cost, + 0.023, + 'KWD (3 decimals) rounds 0.0225 total_ingredient_cost to 0.023', + ); + + // Restore USD (2 decimals) + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('currency', 'USD')").run(); + + // ── 4. Unknown cost, partial recipe cost, variant portions, and order lifecycle immutability ── + console.log('\n--- 4. Order lifecycle cost freezing & immutability ---'); + const espressoBeans = createSupply(db, { + name: 'Espresso Beans', + baseUnit: 'g', + stockQuantity: 2000, + unitCost: 20, // $20/kg = $0.02/g + unitCostUnit: 'kg', + actorUserId: userId, + }); + const wholeMilk = createSupply(db, { + name: 'Whole Milk', + baseUnit: 'ml', + stockQuantity: 5000, + // No unitCost yet! + actorUserId: userId, + }); + + saveRecipe(db, { + productId: 'prod-latte', + yieldQuantity: 1, + items: [ + { supplyId: espressoBeans.id, quantity: 18, unit: 'g' }, // 18 * 0.02 = $0.36 + { supplyId: wholeMilk.id, quantity: 200, unit: 'ml' }, // unknown cost + ], + }); + + const partialRecipe = getRecipeByProduct(db, 'prod-latte'); + assertEqualOrThrow( + partialRecipe.cost_known, + false, + 'recipe with 1 uncosted supply has cost_known = false', + ); + assertEqualOrThrow( + partialRecipe.unit_cost, + null, + 'recipe with 1 uncosted supply has unit_cost = null (never partial understatement)', + ); + + const { orderRoutes } = require('../main/routes/orders'); + const { orderItemRoutes } = require('../main/routes/order-items'); + const app = createApp({ + '/api/orders': orderRoutes, + '/api/order-items': orderItemRoutes, + }); + registerRoutes(app); + const { baseUrl, server } = await startServer(app); + + try { + // Order placed while Whole Milk has no cost AND Muffin has no recipe + const uncostedOrderRes = await api(baseUrl, '/api/orders', { + method: 'POST', + headers: authHeader, + body: { + type: 'takeaway', + items: [ + { product_id: 'prod-latte', quantity: 2 }, + { product_id: 'prod-muffin', quantity: 1 }, + ], + }, + }); + assertEqualOrThrow(uncostedOrderRes.status, 201, 'uncosted order created'); + const uncostedItems = db + .prepare( + 'SELECT product_id, ingredient_cost, ingredient_cost_known FROM order_items WHERE order_id = ? ORDER BY id', + ) + .all(uncostedOrderRes.data.order.id); + assertEqualOrThrow( + uncostedItems[0].ingredient_cost, + null, + 'partial-recipe item stores ingredient_cost = null', + ); + assertEqualOrThrow( + uncostedItems[0].ingredient_cost_known, + 0, + 'partial-recipe item stores ingredient_cost_known = 0', + ); + assertEqualOrThrow( + uncostedItems[1].ingredient_cost, + null, + 'non-recipe item stores ingredient_cost = null (ignores products.cost)', + ); + assertEqualOrThrow( + uncostedItems[1].ingredient_cost_known, + 0, + 'non-recipe item stores ingredient_cost_known = 0', + ); + + // Now receive Whole Milk with cost via HTTP route: 5 L at $7.50 total -> $0.0015 / ml + const milkRecvRes = await api(baseUrl, `/api/supplies/${wholeMilk.id}/movements`, { + method: 'POST', + headers: authHeader, + body: { + movement_type: 'receive', + quantity: 5, + unit: 'l', + total_cost: 7.5, + reason: 'Dairy delivery', + }, + }); + assertEqualOrThrow(milkRecvRes.status, 201, 'receive movement via HTTP route succeeds'); + assertEqualOrThrow( + milkRecvRes.data.movement.unit_cost, + 0.0015, + 'route returns normalized unit_cost 0.0015/ml', + ); + assertEqualOrThrow(milkRecvRes.data.movement.total_cost, 7.5, 'route returns total_cost 7.5'); + + // Verify full recipe cost is now known: 18g * $0.02 ($0.36) + 200ml * $0.0015 ($0.30) = $0.66 + const fullRecipe = getRecipeByProduct(db, 'prod-latte'); + assertEqualOrThrow(fullRecipe.cost_known, true, 'recipe cost is now known'); + assertEqualOrThrow(fullRecipe.unit_cost, 0.66, 'recipe unit_cost is $0.66'); + + // Add a Large variant with recipe_multiplier = 1.5 -> unit ingredient cost = $0.66 * 1.5 = $0.99 + const nowIso = new Date().toISOString(); + db.prepare( + `INSERT INTO product_variants (id, product_id, name, price, track_inventory, stock_quantity, recipe_multiplier, is_active, created_at, updated_at) + VALUES ('var-latte-reg', 'prod-latte', 'Regular', 5.0, 0, 0, 1, 1, ?, ?), + ('var-latte-lg', 'prod-latte', 'Large', 6.5, 0, 0, 1.5, 1, ?, ?)`, + ).run(nowIso, nowIso, nowIso, nowIso); + const largeVariant = { id: 'var-latte-lg', name: 'Large' }; + + // Place Order 2 with 2 Large Lattes -> frozen ingredient_cost = 2 * $0.99 = $1.98 + const costedOrderRes = await api(baseUrl, '/api/orders', { + method: 'POST', + headers: authHeader, + body: { + type: 'dine_in', + table_id: 'tbl-cost-1', + items: [ + { + product_id: 'prod-latte', + variant_id: largeVariant.id, + quantity: 4, + }, + ], + }, + }); + assertEqualOrThrow(costedOrderRes.status, 201, 'costed order with Large variant created'); + const costedOrderId = costedOrderRes.data.order.id; + const initialLine = db + .prepare('SELECT * FROM order_items WHERE order_id = ? LIMIT 1') + .get(costedOrderId); + assertEqualOrThrow( + initialLine.ingredient_cost_known, + 1, + 'frozen line marks ingredient_cost_known = 1', + ); + assertEqualOrThrow( + initialLine.ingredient_cost, + 3.96, + '4x Large Latte (1.5x portion) freezes ingredient_cost = 4 * 0.99 = 3.96', + ); + + // Now double the supply cost of Espresso Beans ($0.02/g -> $0.04/g) AND edit recipe + updateSupply(db, espressoBeans.id, { unitCost: 40, unitCostUnit: 'kg' }); + saveRecipe(db, { + productId: 'prod-latte', + yieldQuantity: 1, + items: [ + { supplyId: espressoBeans.id, quantity: 25, unit: 'g' }, + { supplyId: wholeMilk.id, quantity: 250, unit: 'ml' }, + ], + }); + + // Verify historical line on costedOrder is untouched + const afterCatalogChangeLine = db + .prepare('SELECT * FROM order_items WHERE id = ?') + .get(initialLine.id); + assertEqualOrThrow( + afterCatalogChangeLine.ingredient_cost, + 3.96, + 'supply cost and recipe edits do not alter existing order_items.ingredient_cost', + ); + + // Reduce pending quantity on costedOrder from 4 -> 2: must scale frozen cost (3.96 -> 1.98), NOT re-read catalog! + const pendingReduceRes = await api( + baseUrl, + `/api/orders/${costedOrderId}/items/${initialLine.id}/edit`, + { + method: 'PATCH', + headers: { ...authHeader, 'Idempotency-Key': 'idem-reduce-pending-cost-1' }, + body: { quantity: 2, expected_edit_version: Number(initialLine.edit_version ?? 0) }, + }, + ); + assertEqualOrThrow(pendingReduceRes.status, 200, 'pending quantity reduction succeeds'); + const activeReducedLine = db + .prepare("SELECT * FROM order_items WHERE order_id = ? AND status = 'pending'") + .get(costedOrderId); + assertEqualOrThrow( + activeReducedLine.ingredient_cost, + 1.98, + 'pending quantity reduction (4 -> 2) scales historical frozen cost (3.96 -> 1.98)', + ); + assertEqualOrThrow( + activeReducedLine.ingredient_cost_known, + 1, + 'pending quantity reduction preserves ingredient_cost_known = 1', + ); + + // Move line to preparing, then reduce quantity 2 -> 1 (started quantity reduction) + await api(baseUrl, `/api/order-items/${activeReducedLine.id}/status`, { + method: 'PATCH', + headers: authHeader, + body: { status: 'preparing' }, + }); + const preparingLine = db + .prepare('SELECT * FROM order_items WHERE id = ?') + .get(activeReducedLine.id); + const startedReduceRes = await api( + baseUrl, + `/api/orders/${costedOrderId}/items/${activeReducedLine.id}/edit`, + { + method: 'PATCH', + headers: { ...authHeader, 'Idempotency-Key': 'idem-reduce-started-cost-1' }, + body: { + quantity: 1, + reason: 'Customer changed mind', + override_pin: '1234', + expected_edit_version: Number(preparingLine.edit_version ?? 0), + }, + }, + ); + assertEqualOrThrow(startedReduceRes.status, 200, 'started quantity reduction succeeds'); + const afterStartedReduce = db + .prepare('SELECT * FROM order_items WHERE id = ?') + .get(activeReducedLine.id); + assertEqualOrThrow( + afterStartedReduce.ingredient_cost, + 0.99, + 'started quantity reduction (2 -> 1) scales historical frozen cost (1.98 -> 0.99)', + ); + assertEqualOrThrow( + afterStartedReduce.ingredient_cost_known, + 1, + 'started quantity reduction preserves ingredient_cost_known = 1', + ); + } finally { + server.close(); + } + } finally { + MIGRATIONS.length = 0; + MIGRATIONS.push(...PRISTINE_MIGRATIONS); + closeDatabase(); + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + } + + console.log('='.repeat(65)); + const { passed, failed, total } = getResults(); + console.log(`${passed}/${total} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error('Test failed with error:', err); + try { + closeDatabase(); + } catch {} + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + process.exit(1); +}); From 3b8580c83a81a2380e0f5baaf3cce359603bda4f Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 11:11:56 -0400 Subject: [PATCH 2/6] feat(reports): add product and period ingredient profitability reporting (#1283) --- main/routes/reports.ts | 163 ++++++++++++- main/services/daily-sales-export-files.ts | 29 ++- main/services/daily-sales-export.ts | 154 ++++++++++++- tests/ingredient-cost-lifecycle.test.ts | 269 ++++++++++++++++++++++ 4 files changed, 595 insertions(+), 20 deletions(-) diff --git a/main/routes/reports.ts b/main/routes/reports.ts index 11cd32b68..e8a5e8b6b 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -10,7 +10,7 @@ import { parseDbTimestamp, tenantBusinessDayStartTime, } from '../db'; -import { requirePermission } from '../services/authorization'; +import { hasPermission, requirePermission } from '../services/authorization'; import { getHttpRequestSignal, trackHttpRequestWork } from '../shutdown'; import * as whatsapp from '../services/whatsapp'; import { parsePhoneE164 } from '../lib/phone'; @@ -30,7 +30,11 @@ import { paymentMethodBreakdown, } from './cash-closures'; import { getCurrencyFractionDigits } from '../countries'; -import { buildDailySalesExportDataset } from '../services/daily-sales-export'; +import { roundCurrencyCost } from '../services/supplies'; +import { + buildDailySalesExportDataset, + type DailySalesCostStatus, +} from '../services/daily-sales-export'; import { dailySalesExportFilename, serializeDailySalesExportCsv, @@ -381,6 +385,8 @@ router.get( .all(minorFactor, start, end); const grossCollected = Number(collections.gross_collected || 0); const refunded = Number(refundTotals.refunded || 0); + const fractionDigits = getCurrencyFractionDigits(getTenantCurrency(db)); + const profitability = computePeriodProfitabilitySummary(db, start, end, fractionDigits); res.json({ financialSummary: { @@ -401,6 +407,7 @@ router.get( attributeRefundsToBillDate: true, }), refunds, + profitability, }, }); } catch (error: any) { @@ -595,6 +602,104 @@ router.get('/sales', requirePermission('reports.view'), (req: Request, res: Resp } }); +type RawProfitabilityAggregate = { + total_quantity: number; + ex_tax_revenue: number; + known_cost_quantity: number; + unknown_cost_quantity: number; + known_cost_revenue: number; + known_ingredient_cost: number; +}; + +function deriveProfitabilityMetrics( + row: RawProfitabilityAggregate, + fractionDigits: number, +): { + net_revenue: number; + known_cost_revenue: number; + known_cost_quantity: number; + unknown_cost_quantity: number; + cost_status: DailySalesCostStatus; + ingredient_cost: number | null; + profit: number | null; + margin_percent: number | null; +} { + const knownQty = Number(row.known_cost_quantity || 0); + const unknownQty = Number(row.unknown_cost_quantity || 0); + const costStatus: DailySalesCostStatus = + knownQty > 0 ? (unknownQty === 0 ? 'known' : 'partial') : 'unknown'; + const netRevenue = roundCurrencyCost(Number(row.ex_tax_revenue || 0), fractionDigits); + const knownCostRevenue = roundCurrencyCost(Number(row.known_cost_revenue || 0), fractionDigits); + if (knownQty <= 0) { + return { + net_revenue: netRevenue, + known_cost_revenue: 0, + known_cost_quantity: 0, + unknown_cost_quantity: unknownQty, + cost_status: costStatus, + ingredient_cost: null, + profit: null, + margin_percent: null, + }; + } + const ingredientCost = roundCurrencyCost(Number(row.known_ingredient_cost || 0), fractionDigits); + const profit = roundCurrencyCost( + new Decimal(knownCostRevenue).minus(ingredientCost), + fractionDigits, + ); + const marginPercent = + knownCostRevenue > 0 + ? new Decimal(profit) + .div(knownCostRevenue) + .mul(100) + .toDecimalPlaces(2, Decimal.ROUND_HALF_UP) + .toNumber() + : null; + return { + net_revenue: netRevenue, + known_cost_revenue: knownCostRevenue, + known_cost_quantity: knownQty, + unknown_cost_quantity: unknownQty, + cost_status: costStatus, + ingredient_cost: ingredientCost, + profit, + margin_percent: marginPercent, + }; +} + +function computePeriodProfitabilitySummary( + db: ReturnType, + windowStart: string, + windowEnd: string, + fractionDigits: number, +) { + const row = db + .prepare( + ` + SELECT + COALESCE(SUM(oi.quantity), 0) as total_quantity, + COALESCE(SUM(oi.subtotal - CASE WHEN oi.tax_type = 'inclusive' THEN COALESCE(oi.tax_amount, 0) ELSE 0 END), 0) as ex_tax_revenue, + COALESCE(SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL THEN oi.quantity ELSE 0 END), 0) as known_cost_quantity, + COALESCE(SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL THEN 0 ELSE oi.quantity END), 0) as unknown_cost_quantity, + COALESCE(SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL + THEN (oi.subtotal - CASE WHEN oi.tax_type = 'inclusive' THEN COALESCE(oi.tax_amount, 0) ELSE 0 END) + ELSE 0 END), 0) as known_cost_revenue, + COALESCE(SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL THEN oi.ingredient_cost ELSE 0 END), 0) as known_ingredient_cost + FROM order_items oi + JOIN orders o ON oi.order_id = o.id + WHERE o.created_at >= ? AND o.created_at < ? + AND o.status != 'cancelled' + AND COALESCE(oi.status, 'active') NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded') + `, + ) + .get(windowStart, windowEnd) as RawProfitabilityAggregate; + + return { + total_quantity: Number(row.total_quantity || 0), + ...deriveProfitabilityMetrics(row, fractionDigits), + }; +} + router.get('/topProducts', requirePermission('reports.view'), (req: Request, res: Response) => { try { const db = getDatabase(); @@ -611,13 +716,31 @@ router.get('/topProducts', requirePermission('reports.view'), (req: Request, res : 10; const windowStart = reportDayBounds(startDate)[0]; const windowEnd = reportDayBounds(endDate)[1]; + const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || ''); + const canViewFinancials = userId ? hasPermission(userId, 'reports.financial.view') : false; + const fractionDigits = getCurrencyFractionDigits(getTenantCurrency(db)); + + type RawTopProductRow = RawProfitabilityAggregate & { + product_id: string; + product_name: string; + total_revenue: number; + order_count: number; + }; - const topProducts = db - .prepare(` + const rawTopProducts = db + .prepare( + ` SELECT oi.product_id, oi.product_name, SUM(oi.quantity) as total_quantity, SUM(oi.subtotal) as total_revenue, - COUNT(DISTINCT oi.order_id) as order_count + COUNT(DISTINCT oi.order_id) as order_count, + SUM(oi.subtotal - CASE WHEN oi.tax_type = 'inclusive' THEN COALESCE(oi.tax_amount, 0) ELSE 0 END) as ex_tax_revenue, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL THEN oi.quantity ELSE 0 END) as known_cost_quantity, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL THEN 0 ELSE oi.quantity END) as unknown_cost_quantity, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL + THEN (oi.subtotal - CASE WHEN oi.tax_type = 'inclusive' THEN COALESCE(oi.tax_amount, 0) ELSE 0 END) + ELSE 0 END) as known_cost_revenue, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL THEN oi.ingredient_cost ELSE 0 END) as known_ingredient_cost FROM order_items oi JOIN orders o ON oi.order_id = o.id WHERE o.created_at >= ? AND o.created_at < ? @@ -626,8 +749,34 @@ router.get('/topProducts', requirePermission('reports.view'), (req: Request, res GROUP BY oi.product_id ORDER BY total_quantity DESC LIMIT ? - `) - .all(windowStart, windowEnd, limit); + `, + ) + .all(windowStart, windowEnd, limit) as RawTopProductRow[]; + + const topProducts = rawTopProducts.map((row) => { + const base = { + product_id: row.product_id, + product_name: row.product_name, + total_quantity: Number(row.total_quantity || 0), + total_revenue: Number(row.total_revenue || 0), + order_count: Number(row.order_count || 0), + }; + if (!canViewFinancials) return base; + return { + ...base, + ...deriveProfitabilityMetrics(row, fractionDigits), + }; + }); + + if (canViewFinancials) { + const profitability = computePeriodProfitabilitySummary( + db, + windowStart, + windowEnd, + fractionDigits, + ); + return res.json({ topProducts, profitability }); + } res.json({ topProducts }); } catch (error: any) { diff --git a/main/services/daily-sales-export-files.ts b/main/services/daily-sales-export-files.ts index b8252d204..5591d8cc3 100644 --- a/main/services/daily-sales-export-files.ts +++ b/main/services/daily-sales-export-files.ts @@ -49,8 +49,14 @@ export async function serializeDailySalesExportXlsx( for (const row of dailySalesSummaryMetricRows(dataset.summary)) { const entry = summarySheet.addRow({ metric: row.metric, value: row.value }); - if (typeof row.value === 'number' && !row.metric.endsWith('_count')) { + if ( + typeof row.value === 'number' && + !row.metric.endsWith('_count') && + !row.metric.endsWith('_percent') + ) { entry.getCell('value').numFmt = moneyFmt; + } else if (typeof row.value === 'number' && row.metric.endsWith('_percent')) { + entry.getCell('value').numFmt = '0.00'; } } @@ -64,6 +70,10 @@ export async function serializeDailySalesExportXlsx( { header: 'item_discounts', key: 'item_discounts', width: 16 }, { header: 'net_item_sales', key: 'net_item_sales', width: 16 }, { header: 'tax_amount', key: 'tax_amount', width: 14 }, + { header: 'ingredient_cost', key: 'ingredient_cost', width: 16 }, + { header: 'profit', key: 'profit', width: 16 }, + { header: 'margin_percent', key: 'margin_percent', width: 16 }, + { header: 'cost_status', key: 'cost_status', width: 14 }, ]; itemsSheet.getRow(1).font = { bold: true }; itemsSheet.views = [{ state: 'frozen', ySplit: 1 }]; @@ -78,6 +88,10 @@ export async function serializeDailySalesExportXlsx( item_discounts: item.item_discounts, net_item_sales: item.net_item_sales, tax_amount: item.tax_amount, + ingredient_cost: item.ingredient_cost ?? '', + profit: item.profit ?? '', + margin_percent: item.margin_percent ?? '', + cost_status: item.cost_status, }); entry.getCell('quantity').numFmt = '#,##0.###'; for (const col of [ @@ -88,6 +102,15 @@ export async function serializeDailySalesExportXlsx( ] as const) { entry.getCell(col).numFmt = moneyFmt; } + if (typeof item.ingredient_cost === 'number') { + entry.getCell('ingredient_cost').numFmt = moneyFmt; + } + if (typeof item.profit === 'number') { + entry.getCell('profit').numFmt = moneyFmt; + } + if (typeof item.margin_percent === 'number') { + entry.getCell('margin_percent').numFmt = '0.00'; + } } const paymentsSheet = workbook.addWorksheet('Payments'); @@ -139,6 +162,10 @@ export function serializeDailySalesExportCsv( item.item_discounts, item.net_item_sales, item.tax_amount, + item.ingredient_cost ?? '', + item.profit ?? '', + item.margin_percent ?? '', + item.cost_status, ]), ); } diff --git a/main/services/daily-sales-export.ts b/main/services/daily-sales-export.ts index bf9d5f48a..9f23ddd79 100644 --- a/main/services/daily-sales-export.ts +++ b/main/services/daily-sales-export.ts @@ -1,4 +1,5 @@ import type { Database } from 'better-sqlite3'; +import Decimal from 'decimal.js'; import { dayBoundsInTimezone, getDatabase, @@ -6,12 +7,19 @@ import { getSettingValue, tenantBusinessDayStartTime, } from '../db'; -import { getCurrencyMinorUnitFactor, resolveRegionalSnapshot } from '../countries'; +import { + getCurrencyFractionDigits, + getCurrencyMinorUnitFactor, + resolveRegionalSnapshot, +} from '../countries'; import { getTenantCurrency } from './refund'; +import { roundCurrencyCost } from './supplies'; import { aggregateTaxComponents } from './tax-components'; import { getOrdersWithItemsForBills } from '../routes/bills'; import { paymentMethodBreakdown } from '../routes/cash-closures'; +export type DailySalesCostStatus = 'known' | 'partial' | 'unknown'; + export type DailySalesExportItemRow = { product_id: string; product_name: string; @@ -21,6 +29,10 @@ export type DailySalesExportItemRow = { item_discounts: number; net_item_sales: number; tax_amount: number; + ingredient_cost: number | null; + profit: number | null; + margin_percent: number | null; + cost_status: DailySalesCostStatus; }; export type DailySalesExportPaymentRow = { @@ -52,6 +64,11 @@ export type DailySalesExportSummary = { service_charge_total: number; packaging_charge_total: number; delivery_charge_total: number; + known_cost_net_item_sales: number; + ingredient_cost_total: number | null; + gross_profit_total: number | null; + gross_margin_percent: number | null; + cost_coverage_status: DailySalesCostStatus; payment_methods: DailySalesExportPaymentRow[]; }; @@ -118,6 +135,7 @@ export function buildDailySalesExportDataset( const [start, end] = dayBoundsInTimezone(businessDate, timezone, businessDayStart); const currency = getTenantCurrency(db); const minorFactor = getCurrencyMinorUnitFactor(currency); + const fractionDigits = getCurrencyFractionDigits(currency); const billRow = db .prepare(` @@ -198,7 +216,29 @@ export function buildDailySalesExportDataset( SUM(CASE WHEN b.split_group_id IS NULL OR b.split_group_id = '' THEN COALESCE(oi.tax_amount, 0) ELSE COALESCE(oi.tax_amount, 0) * bi.quantity / NULLIF(oi.quantity, 0) - END) AS tax_amount + END) AS tax_amount, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL + THEN (CASE WHEN b.split_group_id IS NULL OR b.split_group_id = '' THEN oi.quantity ELSE bi.quantity END) + ELSE 0 + END) AS known_cost_quantity, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL + THEN 0 + ELSE (CASE WHEN b.split_group_id IS NULL OR b.split_group_id = '' THEN oi.quantity ELSE bi.quantity END) + END) AS unknown_cost_quantity, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL + THEN (CASE WHEN b.split_group_id IS NULL OR b.split_group_id = '' + THEN (oi.subtotal - CASE WHEN oi.tax_type = 'inclusive' THEN COALESCE(oi.tax_amount, 0) ELSE 0 END) + ELSE (oi.subtotal - CASE WHEN oi.tax_type = 'inclusive' THEN COALESCE(oi.tax_amount, 0) ELSE 0 END) * bi.quantity / NULLIF(oi.quantity, 0) + END) + ELSE 0 + END) AS known_cost_net_item_sales, + SUM(CASE WHEN oi.ingredient_cost_known = 1 AND oi.ingredient_cost IS NOT NULL + THEN (CASE WHEN b.split_group_id IS NULL OR b.split_group_id = '' + THEN oi.ingredient_cost + ELSE oi.ingredient_cost * bi.quantity / NULLIF(oi.quantity, 0) + END) + ELSE 0 + END) AS known_ingredient_cost FROM bills b JOIN orders o ON o.id = b.order_id JOIN order_items oi ON oi.order_id = o.id @@ -218,18 +258,94 @@ export function buildDailySalesExportDataset( item_discounts: number; net_item_sales: number; tax_amount: number; + known_cost_quantity: number; + unknown_cost_quantity: number; + known_cost_net_item_sales: number; + known_ingredient_cost: number; }>; - const items: DailySalesExportItemRow[] = itemRows.map((row) => ({ - product_id: String(row.product_id), - product_name: String(row.product_name), - product_sku: row.product_sku == null ? null : String(row.product_sku), - quantity: Number(row.quantity || 0), - gross_item_sales: Number(row.gross_item_sales || 0), - item_discounts: Number(row.item_discounts || 0), - net_item_sales: Number(row.net_item_sales || 0), - tax_amount: Number(row.tax_amount || 0), - })); + const items: DailySalesExportItemRow[] = itemRows.map((row) => { + const knownQty = Number(row.known_cost_quantity || 0); + const unknownQty = Number(row.unknown_cost_quantity || 0); + const costStatus: DailySalesCostStatus = + knownQty > 0 ? (unknownQty === 0 ? 'known' : 'partial') : 'unknown'; + let ingredientCost: number | null = null; + let profit: number | null = null; + let marginPercent: number | null = null; + if (knownQty > 0) { + ingredientCost = roundCurrencyCost(Number(row.known_ingredient_cost || 0), fractionDigits); + const knownRev = roundCurrencyCost( + Number(row.known_cost_net_item_sales || 0), + fractionDigits, + ); + profit = roundCurrencyCost(new Decimal(knownRev).minus(ingredientCost), fractionDigits); + marginPercent = + knownRev > 0 + ? new Decimal(profit) + .div(knownRev) + .mul(100) + .toDecimalPlaces(2, Decimal.ROUND_HALF_UP) + .toNumber() + : null; + } + return { + product_id: String(row.product_id), + product_name: String(row.product_name), + product_sku: row.product_sku == null ? null : String(row.product_sku), + quantity: Number(row.quantity || 0), + gross_item_sales: Number(row.gross_item_sales || 0), + item_discounts: Number(row.item_discounts || 0), + net_item_sales: Number(row.net_item_sales || 0), + tax_amount: Number(row.tax_amount || 0), + ingredient_cost: ingredientCost, + profit, + margin_percent: marginPercent, + cost_status: costStatus, + }; + }); + + const totalKnownQty = itemRows.reduce( + (sum, row) => sum + Number(row.known_cost_quantity || 0), + 0, + ); + const totalUnknownQty = itemRows.reduce( + (sum, row) => sum + Number(row.unknown_cost_quantity || 0), + 0, + ); + const costCoverageStatus: DailySalesCostStatus = + totalKnownQty > 0 ? (totalUnknownQty === 0 ? 'known' : 'partial') : 'unknown'; + const knownCostNetItemSales = roundCurrencyCost( + itemRows.reduce( + (sum, row) => sum.plus(Number(row.known_cost_net_item_sales || 0)), + new Decimal(0), + ), + fractionDigits, + ); + const ingredientCostTotal = + totalKnownQty > 0 + ? roundCurrencyCost( + itemRows.reduce( + (sum, row) => sum.plus(Number(row.known_ingredient_cost || 0)), + new Decimal(0), + ), + fractionDigits, + ) + : null; + const grossProfitTotal = + ingredientCostTotal !== null + ? roundCurrencyCost( + new Decimal(knownCostNetItemSales).minus(ingredientCostTotal), + fractionDigits, + ) + : null; + const grossMarginPercent = + grossProfitTotal !== null && knownCostNetItemSales > 0 + ? new Decimal(grossProfitTotal) + .div(knownCostNetItemSales) + .mul(100) + .toDecimalPlaces(2, Decimal.ROUND_HALF_UP) + .toNumber() + : null; const itemDiscountsTotal = items.reduce((sum, item) => sum + item.item_discounts, 0); const grossCollected = Number(billRow.gross_collected || 0); @@ -309,6 +425,11 @@ export function buildDailySalesExportDataset( service_charge_total: Number(billRow.service_charge_total || 0), packaging_charge_total: Number(billRow.packaging_charge_total || 0), delivery_charge_total: Number(billRow.delivery_charge_total || 0), + known_cost_net_item_sales: knownCostNetItemSales, + ingredient_cost_total: ingredientCostTotal, + gross_profit_total: grossProfitTotal, + gross_margin_percent: grossMarginPercent, + cost_coverage_status: costCoverageStatus, payment_methods: paymentMethods, }, items, @@ -335,6 +456,11 @@ export function dailySalesSummaryMetricRows( { metric: 'service_charge_total', value: summary.service_charge_total }, { metric: 'packaging_charge_total', value: summary.packaging_charge_total }, { metric: 'delivery_charge_total', value: summary.delivery_charge_total }, + { metric: 'known_cost_net_item_sales', value: summary.known_cost_net_item_sales }, + { metric: 'ingredient_cost_total', value: summary.ingredient_cost_total ?? '' }, + { metric: 'gross_profit_total', value: summary.gross_profit_total ?? '' }, + { metric: 'gross_margin_percent', value: summary.gross_margin_percent ?? '' }, + { metric: 'cost_coverage_status', value: summary.cost_coverage_status }, ]; for (const payment of summary.payment_methods) { rows.push({ @@ -354,6 +480,10 @@ export const DAILY_SALES_ITEM_COLUMNS = [ 'item_discounts', 'net_item_sales', 'tax_amount', + 'ingredient_cost', + 'profit', + 'margin_percent', + 'cost_status', ] as const; export const DAILY_SALES_PAYMENT_COLUMNS = [ diff --git a/tests/ingredient-cost-lifecycle.test.ts b/tests/ingredient-cost-lifecycle.test.ts index fe371523d..0f5d8d406 100644 --- a/tests/ingredient-cost-lifecycle.test.ts +++ b/tests/ingredient-cost-lifecycle.test.ts @@ -597,6 +597,275 @@ async function main() { 1, 'started quantity reduction preserves ingredient_cost_known = 1', ); + + // ----------------------------------------------------------------- + // 5. Sales profitability reports & Daily Sales Export + // ----------------------------------------------------------------- + console.log('\n--- 5. Sales profitability reports & Daily Sales Export ---'); + const reportDateStr = '2026-10-15'; + const reportTs = '2026-10-15 12:00:00'; + const manager = seedManagerUser(db); + + // Seed 3 orders on 2026-10-15 with paid bills: + // - Order A: 1x Latte (unknown cost, $5.00 exclusive) + 1x Bottled Water (unknown cost, $3.00 exclusive) + // - Order B: 2x Latte (known cost $1.98, $13.00 exclusive) + // - Order C: 1x Tax-Inclusive Croissant ($11.00 inclusive with $1.00 tax -> $10.00 ex-tax net revenue, known cost $2.50 -> profit $7.50, margin 75%) + seedProduct(db, 'prod-croissant', 'cat-cost', 'Croissant', 11.0, { + track_inventory: false, + }); + + function seedReportOrderAndBill( + suffix: string, + total: number, + lines: Array<{ + productId: string; + productName: string; + qty: number; + unitPrice: number; + subtotal: number; + taxAmount: number; + taxType: 'exclusive' | 'inclusive'; + ingredientCost: number | null; + ingredientCostKnown: 0 | 1; + }>, + ) { + db.prepare( + `INSERT INTO orders (order_number, user_id, type, status, subtotal, total, created_at, updated_at, completed_at) + VALUES (?, ?, 'takeaway', 'completed', ?, ?, ?, ?, ?)`, + ).run(`ORD-REP-${suffix}`, userId, total, total, reportTs, reportTs, reportTs); + const orderId = Number( + db.prepare('SELECT id FROM orders WHERE order_number = ?').get(`ORD-REP-${suffix}`).id, + ); + for (const line of lines) { + db.prepare( + `INSERT INTO order_items ( + order_id, product_id, product_name, quantity, unit_price, subtotal, + tax_amount, tax_type, total, status, ingredient_cost, ingredient_cost_known, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ready', ?, ?, ?, ?)`, + ).run( + orderId, + line.productId, + line.productName, + line.qty, + line.unitPrice, + line.subtotal, + line.taxAmount, + line.taxType, + line.taxType === 'inclusive' ? line.subtotal : line.subtotal + line.taxAmount, + line.ingredientCost, + line.ingredientCostKnown, + reportTs, + reportTs, + ); + } + db.prepare( + `INSERT INTO bills ( + bill_number, order_id, subtotal, total, paid_amount, balance, payment_status, + payment_details, paid_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, 'paid', ?, ?, ?, ?)`, + ).run( + `BILL-REP-${suffix}`, + orderId, + total, + total, + total, + JSON.stringify([{ method: 'cash', amount: total, timestamp: reportTs }]), + reportTs, + reportTs, + reportTs, + ); + } + + seedReportOrderAndBill('A', 8, [ + { + productId: 'prod-latte', + productName: 'Latte', + qty: 1, + unitPrice: 5, + subtotal: 5, + taxAmount: 0, + taxType: 'exclusive', + ingredientCost: null, + ingredientCostKnown: 0, + }, + { + productId: 'prod-water', + productName: 'Bottled Water', + qty: 1, + unitPrice: 3, + subtotal: 3, + taxAmount: 0, + taxType: 'exclusive', + ingredientCost: null, + ingredientCostKnown: 0, + }, + ]); + seedReportOrderAndBill('B', 13, [ + { + productId: 'prod-latte', + productName: 'Latte', + qty: 2, + unitPrice: 6.5, + subtotal: 13, + taxAmount: 0, + taxType: 'exclusive', + ingredientCost: 1.98, + ingredientCostKnown: 1, + }, + ]); + seedReportOrderAndBill('C', 11, [ + { + productId: 'prod-croissant', + productName: 'Croissant', + qty: 1, + unitPrice: 11, + subtotal: 11, + taxAmount: 1, + taxType: 'inclusive', + ingredientCost: 2.5, + ingredientCostKnown: 1, + }, + ]); + + const topOwnerRes = await api( + baseUrl, + `/api/reports/topProducts?start_date=${reportDateStr}&end_date=${reportDateStr}&limit=10`, + { headers: authHeader }, + ); + assertEqualOrThrow(topOwnerRes.status, 200, 'owner topProducts returns 200'); + const rows = topOwnerRes.data.topProducts as any[]; + const latteRow = rows.find((r) => r.product_id === 'prod-latte'); + const waterRow = rows.find((r) => r.product_id === 'prod-water'); + const croissantRow = rows.find((r) => r.product_id === 'prod-croissant'); + + // Water: 100% unknown cost -> never 100% margin + assertEqualOrThrow(waterRow.cost_status, 'unknown', 'water cost_status is unknown'); + assertEqualOrThrow(waterRow.ingredient_cost, null, 'water ingredient_cost is null'); + assertEqualOrThrow(waterRow.profit, null, 'water profit is null'); + assertEqualOrThrow(waterRow.margin_percent, null, 'water margin_percent is null'); + + // Latte: 1 unknown ($5) + 2 known ($13, cost $1.98) -> partial cost, profit over $13 known_cost_revenue + assertEqualOrThrow(latteRow.cost_status, 'partial', 'latte cost_status is partial'); + assertEqualOrThrow(latteRow.known_cost_quantity, 2, 'latte known_cost_quantity is 2'); + assertEqualOrThrow(latteRow.unknown_cost_quantity, 1, 'latte unknown_cost_quantity is 1'); + assertEqualOrThrow(latteRow.net_revenue, 18, 'latte total ex-tax net_revenue is 18'); + assertEqualOrThrow(latteRow.known_cost_revenue, 13, 'latte known_cost_revenue is 13'); + assertEqualOrThrow(latteRow.ingredient_cost, 1.98, 'latte ingredient_cost is 1.98'); + assertEqualOrThrow(latteRow.profit, 11.02, 'latte profit is 13 - 1.98 = 11.02'); + assertEqualOrThrow( + latteRow.margin_percent, + 84.77, + 'latte margin_percent is 11.02 / 13 = 84.77%', + ); + + // Croissant: inclusive tax ($11 subtotal - $1 inclusive tax = $10 ex-tax revenue), cost $2.50 -> profit $7.50, margin 75% + assertEqualOrThrow(croissantRow.cost_status, 'known', 'croissant cost_status is known'); + assertEqualOrThrow( + croissantRow.net_revenue, + 10, + 'croissant ex-tax net_revenue subtracts inclusive tax ($11 - $1 = $10)', + ); + assertEqualOrThrow(croissantRow.ingredient_cost, 2.5, 'croissant ingredient_cost is 2.5'); + assertEqualOrThrow(croissantRow.profit, 7.5, 'croissant profit is 7.5'); + assertEqualOrThrow(croissantRow.margin_percent, 75, 'croissant margin_percent is 75%'); + + // Period summary in topProducts and financial-summary: + // Total net_revenue = 18 + 3 + 10 = 31; known_cost_revenue = 13 + 10 = 23; ingredient_cost = 1.98 + 2.50 = 4.48; profit = 18.52; margin = 80.52% + const periodProf = topOwnerRes.data.profitability; + assertEqualOrThrow(periodProf.cost_status, 'partial', 'period cost_status is partial'); + assertEqualOrThrow(periodProf.net_revenue, 31, 'period ex-tax net_revenue is 31'); + assertEqualOrThrow(periodProf.known_cost_revenue, 23, 'period known_cost_revenue is 23'); + assertEqualOrThrow(periodProf.ingredient_cost, 4.48, 'period ingredient_cost is 4.48'); + assertEqualOrThrow(periodProf.profit, 18.52, 'period profit is 18.52'); + assertEqualOrThrow(periodProf.margin_percent, 80.52, 'period margin_percent is 80.52%'); + + const finSumRes = await api( + baseUrl, + `/api/reports/financial-summary?start_date=${reportDateStr}&end_date=${reportDateStr}`, + { headers: authHeader }, + ); + assertEqualOrThrow(finSumRes.status, 200, 'financial-summary returns 200'); + assertEqualOrThrow( + finSumRes.data.financialSummary.profitability.profit, + 18.52, + 'financial-summary includes matching period profitability', + ); + + // Manager without reports.financial.view can view topProducts quantities/revenue, but NOT cost/profit/margin + const topManagerRes = await api( + baseUrl, + `/api/reports/topProducts?start_date=${reportDateStr}&end_date=${reportDateStr}&limit=10`, + { headers: manager.authHeader }, + ); + assertEqualOrThrow(topManagerRes.status, 200, 'manager topProducts returns 200'); + assertEqualOrThrow( + topManagerRes.data.profitability, + undefined, + 'manager topProducts omits period profitability without reports.financial.view', + ); + assertEqualOrThrow( + topManagerRes.data.topProducts[0].ingredient_cost, + undefined, + 'manager topProducts omits item ingredient_cost without reports.financial.view', + ); + assertEqualOrThrow( + topManagerRes.data.topProducts[0].profit, + undefined, + 'manager topProducts omits item profit without reports.financial.view', + ); + + // Daily Sales Export CSV (summary + items) includes profitability columns and metrics + const exportSummaryRes = await fetch( + `${baseUrl}/api/reports/daily-sales/export?date=${reportDateStr}&format=csv&part=summary`, + { headers: authHeader }, + ); + assertEqualOrThrow(exportSummaryRes.status, 200, 'daily-sales summary CSV returns 200'); + const exportSummaryCsv = await exportSummaryRes.text(); + assertOrThrow( + exportSummaryCsv.includes('known_cost_net_item_sales,23'), + 'summary CSV includes known_cost_net_item_sales=23', + ); + assertOrThrow( + exportSummaryCsv.includes('ingredient_cost_total,4.48'), + 'summary CSV includes ingredient_cost_total=4.48', + ); + assertOrThrow( + exportSummaryCsv.includes('gross_profit_total,18.52'), + 'summary CSV includes gross_profit_total=18.52', + ); + assertOrThrow( + exportSummaryCsv.includes('gross_margin_percent,80.52'), + 'summary CSV includes gross_margin_percent=80.52', + ); + assertOrThrow( + exportSummaryCsv.includes('cost_coverage_status,partial'), + 'summary CSV includes cost_coverage_status=partial', + ); + + const exportItemsRes = await fetch( + `${baseUrl}/api/reports/daily-sales/export?date=${reportDateStr}&format=csv&part=items`, + { headers: authHeader }, + ); + assertEqualOrThrow(exportItemsRes.status, 200, 'daily-sales items CSV returns 200'); + const exportItemsCsv = await exportItemsRes.text(); + assertOrThrow( + exportItemsCsv.includes( + 'product_id,product_name,product_sku,quantity,gross_item_sales,item_discounts,net_item_sales,tax_amount,ingredient_cost,profit,margin_percent,cost_status', + ), + 'items CSV header includes ingredient_cost,profit,margin_percent,cost_status', + ); + assertOrThrow( + exportItemsCsv.includes('prod-water,Bottled Water,,1,3,0,3,0,,,,unknown'), + 'unknown-cost item exports empty cells for cost/profit/margin and cost_status=unknown', + ); + assertOrThrow( + exportItemsCsv.includes('prod-latte,Latte,,3,18,0,18,0,1.98,11.02,84.77,partial'), + 'partial-cost item exports known cost/profit/margin and cost_status=partial', + ); + assertOrThrow( + exportItemsCsv.includes('prod-croissant,Croissant,,1,11,0,11,1,2.5,7.5,75,known'), + 'known-cost tax-inclusive item exports ex-tax profit/margin and cost_status=known', + ); } finally { server.close(); } From 82d553308e021703e1522f11808dbd036e08ecc1 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Fri, 23 Oct 2026 11:20:37 -0400 Subject: [PATCH 3/6] feat(ui): add supply cost entry, recipe margin preview, and profitability dashboard (#1283) --- docs/reference/product-invariants.md | 32 +- .../src/app/(dashboard)/dashboard/page.tsx | 125 ++++++- .../src/app/(dashboard)/inventory/page.tsx | 307 +++++++++++++++--- frontend/src/lib/i18n/messages/ar.json | 15 +- frontend/src/lib/i18n/messages/bn.json | 15 +- frontend/src/lib/i18n/messages/de.json | 15 +- frontend/src/lib/i18n/messages/en.json | 15 +- frontend/src/lib/i18n/messages/es.json | 15 +- frontend/src/lib/i18n/messages/fa.json | 15 +- frontend/src/lib/i18n/messages/fil.json | 15 +- frontend/src/lib/i18n/messages/fr.json | 15 +- frontend/src/lib/i18n/messages/hi.json | 15 +- frontend/src/lib/i18n/messages/id.json | 15 +- frontend/src/lib/i18n/messages/it.json | 15 +- frontend/src/lib/i18n/messages/ja.json | 15 +- frontend/src/lib/i18n/messages/ko.json | 15 +- frontend/src/lib/i18n/messages/ne.json | 15 +- frontend/src/lib/i18n/messages/nl.json | 15 +- frontend/src/lib/i18n/messages/pt.json | 15 +- frontend/src/lib/i18n/messages/ru.json | 15 +- frontend/src/lib/i18n/messages/sq.json | 15 +- frontend/src/lib/i18n/messages/th.json | 15 +- frontend/src/lib/i18n/messages/tr.json | 15 +- frontend/src/lib/i18n/messages/ur.json | 15 +- frontend/src/lib/i18n/messages/vi.json | 15 +- frontend/src/lib/i18n/messages/zh-tw.json | 15 +- frontend/src/lib/i18n/messages/zh.json | 15 +- 27 files changed, 720 insertions(+), 104 deletions(-) diff --git a/docs/reference/product-invariants.md b/docs/reference/product-invariants.md index 1c4ee381e..74bb635aa 100644 --- a/docs/reference/product-invariants.md +++ b/docs/reference/product-invariants.md @@ -326,16 +326,39 @@ tables (`supplies`, `supply_movements`) with a signed ledger, separate from prod to later sales after a recipe becomes active. Portions must be positive finite numbers; catalog writes and database imports reject invalid supplied values, and recipe snapshot creation rejects an invalid portion before depletion. +8. **Supply purchase costs use Weighted Average Cost (WAC) per base unit and stay separate from the + expenses ledger.** A supply's `unit_cost` is stored in major currency units per `base_unit` + (rounded to 10 decimal places so sub-minor-unit gram and millilitre costs never collapse to zero, + including 0-decimal currencies such as JPY). When a `receive` movement includes a batch cost + (`unit_cost` per movement unit or `total_cost` for the batch), it is normalized to `base_unit`. + If `stock_before <= 0` or `supplies.unit_cost IS NULL`, `unit_cost` resets to the new batch cost; + otherwise it blends by WAC (`(stock_before * old_cost + delta * batch_cost) / stock_after`). + Unpriced `receive` movements leave `supplies.unit_cost` unchanged. Recording a supply receive cost + updates inventory valuation only and never creates an `expenses` row. +9. **Order-item ingredient cost is recipe-derived, frozen at sale time, and never understated.** + When an order line is created, `buildRecipeSnapshot()` computes the line's ingredient cost from + its active recipe and variant `recipe_multiplier`, rounds the line total to the tenant currency's + fraction digits (`0`, `2`, or `3`), and freezes `ingredient_cost` and `ingredient_cost_known` on + `order_items` alongside `recipe_snapshot`. If a product has no active recipe or **any** recipe + component supply has `unit_cost IS NULL`, `ingredient_cost` is `NULL` and `ingredient_cost_known` + is `0` ("cost unknown" - never a partial cost and never 100% margin); legacy `products.cost` is + not used. Subsequent supply cost or recipe edits never alter existing `order_items`, and pending + or started quantity reductions scale the frozen cost from the line's held `recipe_snapshot`. In + sales reports and daily sales exports (gated by `reports.financial.view` and + `reports.daily-sales.export`), profit and margin are computed strictly over ex-tax net item sales + of known-cost lines (`known_cost_revenue`). **Reason:** POS availability during a stockout matters more than ledger neatness. A store that cannot sell because flour has not been counted in yet is worse than a negative row to reconcile -later. Snapshots keep historical cancellations correct under recipe edits, mirroring the existing -`tax_snapshot` pattern. +later. Snapshots keep historical cancellations and historical profitability reports accurate under +recipe or supply cost edits, mirroring the existing `tax_snapshot` pattern. **Enforced by:** `applySupplyStockChange()` in `main/services/supplies.ts`, which does not clamp; -`buildRecipeSnapshot()` and `applyRecipeSnapshot()` in `main/services/recipes.ts`; the order +`buildRecipeSnapshot()`, `scaleRecipeSnapshot()`, and `applyRecipeSnapshot()` in +`main/services/recipes.ts`; the order creation, item append, order and item cancel, item edit, and item restore paths in -`main/routes/orders.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its +`main/routes/orders.ts`; and profitability aggregation in `main/routes/reports.ts` and +`main/services/daily-sales-export.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its inventory and recipe components, and returns it to `pending`; a `voided` or superseded item is never restored. The portion comes from @@ -348,6 +371,7 @@ before depletion. The migration gives existing variants a default portion of 1 i ```sh npm run test:recipe-order-lifecycle # deplete, restore, void-no-restore, snapshot immutability +npm run test:ingredient-cost # WAC, negative-stock reset, frozen cost, profitability reports npm run test:order-item-edit # pending vs started item edits, waste, superseded restore rejection npm run test:supplies-service # negative stock, ledger, pagination npm run test:product-variants-orders # half / whole / double portions across create, append, void diff --git a/frontend/src/app/(dashboard)/dashboard/page.tsx b/frontend/src/app/(dashboard)/dashboard/page.tsx index 3c7c12b3d..60326de3f 100644 --- a/frontend/src/app/(dashboard)/dashboard/page.tsx +++ b/frontend/src/app/(dashboard)/dashboard/page.tsx @@ -90,6 +90,18 @@ interface RefundActivity { approved_by_name: string; } +interface PeriodProfitability { + total_quantity: number; + net_revenue: number; + known_cost_revenue: number; + known_cost_quantity: number; + unknown_cost_quantity: number; + cost_status: 'known' | 'partial' | 'unknown'; + ingredient_cost: number | null; + profit: number | null; + margin_percent: number | null; +} + interface FinancialSummary { startDate: string; endDate: string; @@ -101,14 +113,23 @@ interface FinancialSummary { averageOrderValue: number; paymentMethods: PaymentMethodBreakdown[]; refunds: RefundActivity[]; + profitability?: PeriodProfitability; } interface TopProduct { - product_id: number; + product_id: number | string; product_name: string; total_quantity: number; total_revenue: number; order_count: number; + net_revenue?: number; + known_cost_revenue?: number; + known_cost_quantity?: number; + unknown_cost_quantity?: number; + cost_status?: 'known' | 'partial' | 'unknown'; + ingredient_cost?: number | null; + profit?: number | null; + margin_percent?: number | null; } interface RecentOrder { @@ -811,6 +832,57 @@ export default function DashboardPage() { )} + {canViewFinancials && financialSummary?.profitability && ( +
+
+ + + +
+

{t('ingredientCost')}

+

+ {financialSummary.profitability.ingredient_cost !== null + ? fmt(Number(financialSummary.profitability.ingredient_cost)) + : t('costUnknown')} +

+
+
+
+ + + +
+

{t('grossProfit')}

+

+ {financialSummary.profitability.profit !== null + ? fmt(Number(financialSummary.profitability.profit)) + : t('costUnknown')} +

+
+
+
+ + + +
+

{t('grossMargin')}

+
+

+ {financialSummary.profitability.margin_percent !== null + ? `${financialSummary.profitability.margin_percent}%` + : t('costUnknown')} +

+ {financialSummary.profitability.cost_status === 'partial' && ( + + {t('partialCost')} + + )} +
+
+
+
+ )} +
{/* Recent Orders */}
@@ -912,12 +984,19 @@ export default function DashboardPage() { {topProducts.map((product) => (
- - {product.product_name} - +
+ + {product.product_name} + + {canViewFinancials && product.cost_status === 'partial' && ( + + {t('partialCost')} + + )} +

{t('productSoldOrders', { quantity: product.total_quantity, @@ -925,9 +1004,39 @@ export default function DashboardPage() { })}

- - {fmt(Number(product.total_revenue))} - +
+ + {fmt(Number(product.total_revenue))} + + {canViewFinancials && product.cost_status && ( +
+ {product.cost_status === 'unknown' || + product.ingredient_cost === null || + product.ingredient_cost === undefined ? ( + {t('costUnknown')} + ) : ( + + {t('ingredientCostLabel', { + amount: fmt(Number(product.ingredient_cost)), + })} + {product.profit !== null && product.profit !== undefined && ( + <> + {' · '} + {t('profitMarginLabel', { + profit: fmt(Number(product.profit)), + margin: + product.margin_percent !== null && + product.margin_percent !== undefined + ? `${product.margin_percent}%` + : '—', + })} + + )} + + )} +
+ )} +
))}
diff --git a/frontend/src/app/(dashboard)/inventory/page.tsx b/frontend/src/app/(dashboard)/inventory/page.tsx index 8a572ba77..7daece3f6 100644 --- a/frontend/src/app/(dashboard)/inventory/page.tsx +++ b/frontend/src/app/(dashboard)/inventory/page.tsx @@ -20,6 +20,7 @@ import { Scale, } from 'lucide-react'; import { useTranslations } from 'use-intl'; +import { useFormatCurrency } from '@/hooks/useFormatCurrency'; import { useFormatDate } from '@/hooks/useFormatDate'; import { useFormatNumber } from '@/hooks/useFormatNumber'; import { tenantCan } from '@/lib/permissions'; @@ -33,6 +34,7 @@ interface Supply { base_unit: SupplyUnit; stock_quantity: number; low_stock_threshold: number | null; + unit_cost: number | null; is_active: number; is_low_stock?: number; } @@ -45,6 +47,9 @@ interface SupplyMovement { movement_type: string; unit: SupplyUnit; stock_after: number; + unit_cost?: number | null; + total_cost?: number | null; + unit_cost_after?: number | null; reason: string | null; actor_name: string | null; created_at: string; @@ -57,6 +62,8 @@ interface RecipeItem { quantity: number; unit: SupplyUnit; quantity_in_base: number; + supply_unit_cost?: number | null; + component_cost?: number | null; } interface Recipe { @@ -65,12 +72,25 @@ interface Recipe { product_name: string | null; yield_quantity: number; is_active: number; + cost_known?: boolean; + total_cost?: number | null; + unit_cost?: number | null; items: RecipeItem[]; } interface Product { id: string; name: string; + price?: number; +} + +function convertUnitClient(quantity: number, from: SupplyUnit, to: SupplyUnit): number | null { + if (from === to) return quantity; + if (from === 'kg' && to === 'g') return quantity * 1000; + if (from === 'g' && to === 'kg') return quantity / 1000; + if (from === 'l' && to === 'ml') return quantity * 1000; + if (from === 'ml' && to === 'l') return quantity / 1000; + return null; } const movementTypeIcons: Record = { @@ -88,6 +108,7 @@ export default function InventoryPage() { const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); const { formatDate } = useFormatDate(); + const fmt = useFormatCurrency(); const fmtNum = useFormatNumber(); const canManage = tenantCan(currentTenant, 'inventory.manage'); @@ -116,6 +137,8 @@ export default function InventoryPage() { base_unit: 'each' as SupplyUnit, stock_quantity: '0', low_stock_threshold: '', + unit_cost: '', + unit_cost_unit: 'each' as SupplyUnit, }); const [movementSupply, setMovementSupply] = useState(null); @@ -123,6 +146,8 @@ export default function InventoryPage() { movement_type: 'receive', quantity: '', unit: 'each' as SupplyUnit, + cost_mode: 'unit_cost' as 'unit_cost' | 'total_cost', + cost_amount: '', reason: '', }); @@ -234,7 +259,14 @@ export default function InventoryPage() { const openAddSupply = () => { setEditingSupply(null); - setSupplyForm({ name: '', base_unit: 'each', stock_quantity: '0', low_stock_threshold: '' }); + setSupplyForm({ + name: '', + base_unit: 'each', + stock_quantity: '0', + low_stock_threshold: '', + unit_cost: '', + unit_cost_unit: 'each', + }); setShowSupplyForm(true); }; @@ -245,6 +277,8 @@ export default function InventoryPage() { base_unit: s.base_unit, stock_quantity: String(s.stock_quantity), low_stock_threshold: s.low_stock_threshold === null ? '' : String(s.low_stock_threshold), + unit_cost: s.unit_cost === null || s.unit_cost === undefined ? '' : String(s.unit_cost), + unit_cost_unit: s.base_unit, }); setShowSupplyForm(true); }; @@ -252,17 +286,22 @@ export default function InventoryPage() { const handleSaveSupply = async (e: React.FormEvent) => { e.preventDefault(); try { + const unitCostVal = supplyForm.unit_cost === '' ? null : Number(supplyForm.unit_cost); const payload = { name: supplyForm.name, base_unit: supplyForm.base_unit, stock_quantity: Number(supplyForm.stock_quantity), low_stock_threshold: supplyForm.low_stock_threshold === '' ? null : Number(supplyForm.low_stock_threshold), + unit_cost: unitCostVal, + unit_cost_unit: supplyForm.unit_cost_unit, }; if (editingSupply) { await api.put(`/supplies/${editingSupply.id}`, { name: payload.name, low_stock_threshold: payload.low_stock_threshold, + unit_cost: payload.unit_cost, + unit_cost_unit: payload.unit_cost_unit, }); toast.success(tCommon('save')); } else { @@ -284,15 +323,32 @@ export default function InventoryPage() { e.preventDefault(); if (!movementSupply) return; try { + const costNum = + movementForm.movement_type === 'receive' && movementForm.cost_amount !== '' + ? Number(movementForm.cost_amount) + : undefined; await api.post(`/supplies/${movementSupply.id}/movements`, { movement_type: movementForm.movement_type, quantity: Number(movementForm.quantity), unit: movementForm.unit, + ...(costNum !== undefined && movementForm.cost_mode === 'unit_cost' + ? { unit_cost: costNum } + : {}), + ...(costNum !== undefined && movementForm.cost_mode === 'total_cost' + ? { total_cost: costNum } + : {}), reason: movementForm.reason || undefined, }); toast.success(tCommon('save')); setMovementSupply(null); - setMovementForm({ movement_type: 'receive', quantity: '', unit: 'each', reason: '' }); + setMovementForm({ + movement_type: 'receive', + quantity: '', + unit: 'each', + cost_mode: 'unit_cost', + cost_amount: '', + reason: '', + }); refresh(); } catch (err: unknown) { const message = @@ -478,6 +534,9 @@ export default function InventoryPage() { {t('stock')} + + {t('unitCost')} + {t('threshold')} @@ -506,6 +565,25 @@ export default function InventoryPage() { > {fmtNum(Number(s.stock_quantity))} {s.base_unit} + + {s.unit_cost === null || s.unit_cost === undefined ? ( + {t('costUnknown')} + ) : ( +
+ + {fmt(Number(s.unit_cost))} / {s.base_unit} + + {(s.base_unit === 'g' || s.base_unit === 'ml') && + Number(s.unit_cost) > 0 && + Number(s.unit_cost) < 0.1 && ( +
+ ({fmt(Number(s.unit_cost) * 1000)} /{' '} + {s.base_unit === 'g' ? 'kg' : 'l'}) +
+ )} +
+ )} + {s.low_stock_threshold === null ? '—' : fmtNum(Number(s.low_stock_threshold))} @@ -523,6 +601,8 @@ export default function InventoryPage() { movement_type: 'receive', quantity: '', unit: s.base_unit, + cost_mode: 'unit_cost', + cost_amount: '', reason: '', }); }} @@ -585,49 +665,87 @@ export default function InventoryPage() { {t('components')} + + {t('recipeCost')} + {t('actions')} - {visibleRecipes.map((r) => ( - - - {r.product_name || r.product_id} - - {fmtNum(Number(r.yield_quantity))} - -
    - {r.items.map((item) => ( -
  • - {item.supply_name}: {fmtNum(Number(item.quantity))} {item.unit} -
  • - ))} -
- - -
- - -
- - - ))} + {visibleRecipes.map((r) => { + const productObj = products.find((p) => p.id === r.product_id); + const sellPrice = + productObj?.price !== undefined ? Number(productObj.price) : null; + const unitCost = + r.cost_known && r.unit_cost !== null && r.unit_cost !== undefined + ? Number(r.unit_cost) + : null; + const marginPct = + unitCost !== null && sellPrice !== null && sellPrice > 0 + ? Math.round(((sellPrice - unitCost) / sellPrice) * 1000) / 10 + : null; + return ( + + + {r.product_name || r.product_id} + + + {fmtNum(Number(r.yield_quantity))} + + +
    + {r.items.map((item) => ( +
  • + {item.supply_name}: {fmtNum(Number(item.quantity))} {item.unit} + {item.component_cost !== null && + item.component_cost !== undefined && ( + + ({fmt(Number(item.component_cost))}) + + )} +
  • + ))} +
+ + + {unitCost !== null ? ( +
+ {fmt(unitCost)} + {marginPct !== null && ( +
+ {fmt(sellPrice! - unitCost)} ({marginPct}%) +
+ )} +
+ ) : ( + {t('costUnknown')} + )} + + +
+ + +
+ + + ); + })} {visibleRecipes.length === 0 && ( @@ -721,8 +839,17 @@ export default function InventoryPage() { - {Number(m.quantity_delta) > 0 ? '+' : ''} - {fmtNum(Number(m.quantity_delta))} {m.unit} +
+ {Number(m.quantity_delta) > 0 ? '+' : ''} + {fmtNum(Number(m.quantity_delta))} {m.unit} +
+ {m.movement_type === 'receive' && + m.total_cost !== null && + m.total_cost !== undefined && ( +
+ {fmt(Number(m.total_cost))} +
+ )} {fmtNum(Number(m.stock_after))} {m.unit} @@ -774,9 +901,14 @@ export default function InventoryPage() { <> setSupplyForm({ ...supplyForm, unit_cost: e.target.value })} + className="flex-1 px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> + +
+ {movementForm.movement_type === 'receive' && ( +
+ + + setMovementForm({ ...movementForm, cost_amount: e.target.value }) + } + className="flex-1 min-w-0 px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> +
+ )} ))} + {recipeForm.items.length > 0 && ( +
+ {t('recipeCost')}: + + {(() => { + const yieldQty = Number(recipeForm.yield_quantity); + if (!(yieldQty > 0)) return t('costUnknown'); + let total = 0; + for (const entry of recipeForm.items) { + const sup = supplies.find((s) => s.id === entry.supply_id); + const qty = Number(entry.quantity); + if ( + !sup || + sup.unit_cost === null || + sup.unit_cost === undefined || + !(qty > 0) + ) { + return t('costUnknown'); + } + const converted = convertUnitClient(qty, entry.unit, sup.base_unit); + if (converted === null) return t('costUnknown'); + total += converted * Number(sup.unit_cost); + } + return fmt(total / yieldQty); + })()} + +
+ )}