From 85b2249524862d00086dc9c9f7da91a3409da0fa Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 1 Oct 2026 21:38:36 +0530 Subject: [PATCH 1/2] Enforce server-owned order pricing and tax admission --- api/src/controllers/sales.controller.js | 7 +- api/src/models/sale.model.js | 22 +- api/src/models/setting.model.js | 10 + api/src/repositories/item.repository.js | 41 +- api/src/repositories/sale.repository.js | 473 +++++++----------- api/src/routes/billing-outlets.routes.js | 7 +- api/src/services/pricing-authority.js | 267 ++++++++++ api/src/services/sale.service.js | 169 ++++--- api/src/sync/collections.json | 8 +- api/tests/unit/models/sale.model.test.js | 29 ++ .../a-dish-priced-on-the-day.test.js | 8 +- .../order-pricing-authority.test.js | 146 ++++++ .../unit/services/pricing-authority.test.js | 92 ++++ api/tests/unit/services/sale.service.test.js | 43 +- docs/AUTHORITATIVE_ORDER_PRICING.md | 70 +++ 15 files changed, 1002 insertions(+), 390 deletions(-) create mode 100644 api/src/services/pricing-authority.js create mode 100644 api/tests/unit/repositories/order-pricing-authority.test.js create mode 100644 api/tests/unit/services/pricing-authority.test.js create mode 100644 docs/AUTHORITATIVE_ORDER_PRICING.md diff --git a/api/src/controllers/sales.controller.js b/api/src/controllers/sales.controller.js index 48b42e726..8bde44bac 100644 --- a/api/src/controllers/sales.controller.js +++ b/api/src/controllers/sales.controller.js @@ -7712,7 +7712,12 @@ class SalesController extends BaseController { if (response.status === true) { return this.success(res, response.data, response.message); } else { - return this.error(res, response.message, 404); + return this.error( + res, + response.message, + response.data?.state ? 422 : 404, + response.data?.state ? response.data : null + ); } } catch (error) { console.error('Error in updateOrder:', error); diff --git a/api/src/models/sale.model.js b/api/src/models/sale.model.js index d2f045b4c..c4bafe2d1 100644 --- a/api/src/models/sale.model.js +++ b/api/src/models/sale.model.js @@ -969,6 +969,13 @@ saleSchema.pre('save', async function () { const src = item && typeof item.toObject === 'function' ? item.toObject() : item || {}; return { + ...(src.pricing + ? { + pricing: src.pricing, + unit_price: src.unit_price, + item_base_price: src.item_base_price, + } + : {}), sale_inline_item_price: src.sale_inline_item_price != null ? src.sale_inline_item_price : 0, sale_inline_discount_value: src.sale_inline_discount_value != null ? src.sale_inline_discount_value : 0, @@ -2069,7 +2076,19 @@ Sale.kioskOrderModel = async function (data) { const discountAmount = parseFloat(doc.discount_amount) || 0; const discountPercentage = parseFloat(doc.discount_percentage) || 0; - const sellingPrice = parseFloat(doc.selling_price) || 0; + const authority = require('../services/pricing-authority'); + let pricing; + try { + pricing = authority.resolve({ + product: doc, + branch: branchDoc, + submitted: item.unit_price ?? item.item_price ?? item.price, + channel: 'kiosk', + }); + } catch (error) { + return authority.failure(error); + } + const sellingPrice = pricing.selling_price; const itemAmount = sellingPrice * itemQuantity; const companyPrice = itemQuantity * (parseFloat(doc.company_price) || 0); total_company_data.push({ company_amount: companyPrice }); @@ -2143,6 +2162,7 @@ Sale.kioskOrderModel = async function (data) { }); itemsale.push({ + pricing, sale_inline_item_price: sellingPrice, sale_inline_discount_value: discountAmount, sale_inline_discount_pervalue: discountPercentage, diff --git a/api/src/models/setting.model.js b/api/src/models/setting.model.js index d74850e76..d13b1ae4a 100644 --- a/api/src/models/setting.model.js +++ b/api/src/models/setting.model.js @@ -3678,6 +3678,11 @@ class SettingModel extends BaseModel { }; } const collection = await this.getCollection('modifier_groups'); + const removed = await collection.findOne({ + _id: this.normalizeId(id), + license: this.normalizeId(this.licenseId), + }); + if (removed) await BaseModel.deletedDocumentBackup('modifier_groups', removed); const r = await collection.deleteOne({ _id: this.normalizeId(id), license: this.normalizeId(this.licenseId), @@ -3794,6 +3799,11 @@ class SettingModel extends BaseModel { try { if (!id) return { status: false, data: null, message: 'List id required' }; const collection = await this.getCollection('price_lists'); + const removed = await collection.findOne({ + _id: this.normalizeId(id), + license: this.normalizeId(this.licenseId), + }); + if (removed) await BaseModel.deletedDocumentBackup('price_lists', removed); const r = await collection.deleteOne({ _id: this.normalizeId(id), license: this.normalizeId(this.licenseId), diff --git a/api/src/repositories/item.repository.js b/api/src/repositories/item.repository.js index 2391d7ebf..49b549974 100644 --- a/api/src/repositories/item.repository.js +++ b/api/src/repositories/item.repository.js @@ -1,3 +1,4 @@ +const pricingAuthority = require('../services/pricing-authority'); const itemText = require('../utils/item-localization'); const { searchPattern } = require('../utils/safe-search'); const tradingDay = require('../utils/trading-day'); @@ -4322,6 +4323,10 @@ class ItemRepository extends BaseModel { { table: params.table, venue: params.venue, unit: params.unit }, await this.shopVenues() ); + if (params.venue && !servicePoint.venue) + throw new Error( + 'Venue pricing is unavailable. Refresh the menu or contact the restaurant.' + ); /* What sells and what sells beside it, from the last month of this branch's own sales. A new shop gets empty maps and simply shows no @@ -4373,7 +4378,7 @@ class ItemRepository extends BaseModel { * in a list being scanned. */ photos: onlineOrdering.photoList(row), - price: partnerVenues.priceFor(Number(row.selling_price) || 0, servicePoint.venue), + ...pricingAuthority.menuQuote(row, branchDoc, servicePoint.venue), diet: String(row.diet || ''), /* * A picture for a dish nobody photographed. @@ -5222,28 +5227,20 @@ class ItemRepository extends BaseModel { { table: params.table, venue: params.venue, unit: params.unit }, await this.shopVenues() ); + if (params.venue && !servicePoint.venue) + throw new Error( + 'Venue pricing is unavailable. Refresh the menu or contact the restaurant.' + ); - if (servicePoint.venue) { - const money = (n) => Math.round((Number(n) || 0) * 100) / 100; - for (const group of results) { - group.items = (group.items || []).map((item) => { - const price = partnerVenues.priceFor(item.price, servicePoint.venue); - const fixed = Number(item.discount_amount) || 0; - const discount = money( - fixed > 0 ? fixed : price * ((Number(item.discount_percentage) || 0) / 100) - ); - const taxable = price - discount; - const rate = (Number(item.tax) || 0) / 100; - const taxPrice = item.tax_type === 'inclusive' ? 0 : money(taxable * rate); - return { - ...item, - price, - discount_price: discount, - tax_price: taxPrice, - final_price: money(item.tax_type === 'exclusive' ? taxable + taxPrice : taxable), - }; - }); - } + for (const group of results) { + group.items = (group.items || []).map((item) => ({ + ...item, + ...pricingAuthority.menuQuote( + { ...item, _id: item.id, selling_price: item.price }, + branchDoc, + servicePoint.venue + ), + })); } /* diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 33ae12427..5009fa3a1 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -1,3 +1,4 @@ +const pricingAuthority = require('../services/pricing-authority'); const Money = require('../utils/currency'); const serviceLine = require('../utils/service-line'); const orderLine = require('../utils/order-line'); @@ -8579,16 +8580,23 @@ class SalesRepository { const chargeDoc = await settingsCollection.findOne({ channel_charges: { $exists: true } }); shopCharges = (chargeDoc && chargeDoc.channel_charges) || {}; } catch (e) { - /* No venues and no fees is the normal state for a shop with one - dining room, and it is also the safe answer if this fails: house - prices, nothing added, nothing owed. */ - console.warn('[order] could not read venues or charges:', e.message); + return { + status: false, + message: 'Pricing settings could not be loaded. Try again before placing the order.', + data: { state: 'pricing_rule_unavailable' }, + }; } const servicePoint = partnerVenues.resolveServicePoint( { table: String(kiosk_table_no || table || '').trim(), venue, unit }, venues ); + if (venue && !servicePoint.venue) + return { + status: false, + message: 'Venue pricing is unavailable. Refresh the menu or contact the restaurant.', + data: { state: 'pricing_rule_unavailable' }, + }; /* Where the food actually goes, as the customer confirmed it. Null for the shop's own floor, which needs no address. */ const deliverTo = partnerVenues.confirmDestination(servicePoint, destination || {}); @@ -8726,12 +8734,15 @@ class SalesRepository { orderDay, orderMinutes, servicePoint, + channel: staffOrder ? 'tableside' : 'online', }); if (priced.status === false) return priced; saleItems.push(priced.line); } - const subtotal = round(saleItems.reduce((s, i) => s + round(i.unit_price * i.quantity), 0)); + const subtotal = round( + saleItems.reduce((s, i) => s + round(i.item_subtotal ?? i.unit_price * i.quantity), 0) + ); const totalTax = round(saleItems.reduce((s, i) => s + i.tax_amount, 0)); const discountAmt = round(Number(kiosk_discount_amount) || 0); const itemDiscountTotal = round( @@ -10715,6 +10726,10 @@ class SalesRepository { // ---------- EDIT FLOW ---------- const existingItems = Array.isArray(orderDoc.items) ? orderDoc.items : []; + const needsRules = items.some( + (item) => !existingItems.find((old) => orderLine.key(old) === orderLine.key(item))?.pricing + ); + const pricingContext = needsRules ? await pricingAuthority.loadContext(db, orderDoc) : {}; orderLine.validate(existingItems); orderLine.validate(items); const oldItemsData = {}; @@ -10749,10 +10764,10 @@ class SalesRepository { const incomingProductIds = []; const updatedItems = [...existingItems]; - for (const item of items) { + for (let item of items) { // Accept item_id as fallback when product_id is absent (KOT / kiosk items) const rawId = item.product_id || item.item_id || ''; - if (!rawId) continue; + if (!rawId) throw new Error('An item id is required.'); const productId = String(rawId); const lineKey = orderLine.key(item); const previousLine = existingItems[existingIndex[lineKey]]; @@ -10783,16 +10798,10 @@ class SalesRepository { }); } const qty = parseFloat(item.quantity || item.item_quantity || 0); - const price = Number( - item.price ?? - item.unit_price ?? - item.item_base_price ?? - item.item_price ?? - previousLine?.unit_price ?? - previousLine?.item_price ?? - 0 - ); - if (!productId || qty <= 0 || price < 0) continue; + const submittedPrice = + item.price ?? item.unit_price ?? item.item_base_price ?? item.item_price; + if (!Number.isFinite(qty) || qty <= 0) + throw new Error('Quantity must be greater than zero.'); const oldQty = oldItemsData[lineKey] ? parseFloat(oldItemsData[lineKey].quantity) : 0; if (oldItemsData[lineKey]) delete oldItemsData[lineKey]; @@ -10805,42 +10814,101 @@ class SalesRepository { }); } if (!itemDoc) { - if (!previousLine) - throw new Error( - 'This product has already been removed, so you can not modify anything.' + if (!previousLine) throw new Error('This product has been removed.'); + if (!previousLine.pricing) { + if (qty > oldQty) + throw new Error( + 'Item pricing cannot be verified. Review this legacy order before adding quantities.' + ); + pricingAuthority.assertPrice( + submittedPrice, + Number(previousLine.unit_price ?? previousLine.item_price), + (n) => Money.fromMinor(Money.toMinor(n, monetary), monetary), + previousLine.item_name ); - // Item not in catalog (e.g. KOT order item) - update in-place using existing data - if (existingIndex[lineKey] !== undefined) { - const i = existingIndex[lineKey]; - updatedItems[i] = { - ...this._scaleOrderLine(updatedItems[i], oldQty, qty, monetary), - ...serviceLine.metadata({ ...updatedItems[i], ...item }), - item_quantity: qty, - quantity: qty, - ...(item.item_description != null - ? { item_description: String(item.item_description) } - : {}), - /* A KOT line the catalogue no longer holds still belongs to - somebody who may have changed their mind about the chillies. */ - ...(item.spice_level != null - ? { spice_level: spiceLevel.levelOf(item.spice_level) } - : {}), + updatedItems[existingIndex[lineKey]] = { + ...this._scaleOrderLine(previousLine, oldQty, qty, monetary), + ...preparation, + item_description: newNote, }; - if (qty !== oldQty) + if (qty < oldQty) changesItems.push({ - ...preparation, item_id: productId, - item_name: previousLine.item_name || '', - ...require('../utils/kitchen-amount').snapshot(previousLine), - item_quantity: Math.abs(qty - oldQty), + ...preparation, + item_name: previousLine.item_name, + item_quantity: oldQty - qty, + process: 'cancel', item_description: newNote, - spice_level: spiceLevel.levelOf(updatedItems[i].spice_level), - process: qty > oldQty ? 'add' : 'cancel', + ...require('../utils/kitchen-amount').snapshot(previousLine), }); incomingProductIds.push(lineKey); + continue; } - continue; + itemDoc = { _id: productId, name: previousLine.item_name }; } + if ( + itemDoc.branch_id && + String(itemDoc.branch_id) !== String(orderDoc.branch_id) && + !(itemDoc.branch_access || []).some( + (b) => String(b.branch_id) === String(orderDoc.branch_id) + ) + ) + throw new Error('Item does not belong to this branch.'); + let pricing; + let amounts; + try { + if (previousLine) { + const storedUnit = previousLine.unit_price ?? previousLine.item_price; + pricingAuthority.assertPrice( + submittedPrice, + Number(storedUnit), + (n) => Money.fromMinor(Money.toMinor(n, monetary), monetary), + previousLine.item_name + ); + if ( + item.modifiers !== undefined && + JSON.stringify(item.modifiers) !== JSON.stringify(previousLine.modifiers || []) + ) + throw new pricingAuthority.PricingError( + 'item_modifiers_changed', + 'Add a separate line to change priced modifiers.' + ); + pricing = pricingAuthority.resolve({ + product: itemDoc, + branch: shop || {}, + previous: previousLine, + submitted: previousLine.pricing + ? undefined + : Number(previousLine.sale_inline_item_price ?? storedUnit), + ...pricingContext, + channel: orderDoc.channel || 'counter', + }); + } else { + if ([1, '1', true].includes(itemDoc.del_status)) + throw new Error('This product has been removed.'); + const extras = await this._priceModifiers(item.modifiers, itemDoc, shop || {}); + if (extras.status === false) return extras; + pricing = pricingAuthority.resolve({ + product: itemDoc, + branch: shop || {}, + submitted: submittedPrice, + extras: extras.delta, + ...pricingContext, + channel: orderDoc.channel || 'counter', + }); + item = { ...item, modifiers: extras.lines }; + } + amounts = pricingAuthority.calculate( + pricing, + qty, + shop || {}, + Number(previousLine?.sale_inline_discount_value ?? itemDoc.discount_amount ?? 0), + Number(previousLine?.item_discount_percentage ?? itemDoc.discount_percentage ?? 0) + ); + } catch (error) { + return pricingAuthority.failure(error); + } + const price = amounts.unit_price; let changeQty = 0; let changeProcess = ''; @@ -10882,139 +10950,34 @@ class SalesRepository { } incomingProductIds.push(lineKey); - const itemTaxRate = parseFloat(itemDoc.tax || 0); - const taxType = itemDoc.tax_type || 'exclusive'; - - if (existingIndex[lineKey] !== undefined) { - const i = existingIndex[lineKey]; - const existing = updatedItems[i]; - const itemAmount = qty * price; - const itemDiscountPer = parseFloat(existing.item_discount_percentage || 0); - const itemDiscountVal = parseFloat(existing.sale_inline_discount_value || 0); - const lineDiscount = (itemAmount * itemDiscountPer) / 100 + itemDiscountVal * qty; - const taxableAmount = itemAmount - lineDiscount; - let taxAmount = 0; - let lineTotal = taxableAmount; - - if (itemTaxRate > 0 && taxType === 'exclusive') { - taxAmount = (taxableAmount * itemTaxRate) / 100; - lineTotal = taxableAmount + taxAmount; - } else if (itemTaxRate > 0 && taxType === 'inclusive') { - lineTotal = taxableAmount; - taxAmount = (lineTotal * itemTaxRate) / (100 + itemTaxRate); - } - - updatedItems[i] = { - ...existing, - ...serviceLine.metadata({ ...existing, ...item }), - ...require('../utils/kitchen-amount').forSaleItem(itemDoc, item, price), - item_quantity: qty, - quantity: qty, - item_price: price, - unit_price: price, - item_base_price: price, - item_total: lineTotal, - total: lineTotal, - item_tax: taxAmount, - item_discount: lineDiscount, - total_amount: lineTotal, - tax: itemTaxRate, - tax_type: taxType, - tax_amount: taxAmount, - cgst_tax: taxAmount / 2, - sgst_tax: taxAmount / 2, - }; - if (item.item_description != null) - updatedItems[i].item_description = String(item.item_description); - /* The ticket is printed from the change record above; THIS is what - the customer sees back on their own order and what a shop counts - later, so a change of mind has to land on both. */ - if (item.spice_level != null) - updatedItems[i].spice_level = spiceLevel.levelOf(item.spice_level); - } else { - const itemQuantity = qty; - const sellingPrice = price; - const discountAmt = parseFloat(itemDoc.discount_amount || 0); - const discountPer = parseFloat(itemDoc.discount_percentage || 0); - const itemAmount = sellingPrice * itemQuantity; - const companyPriceTotal = (itemDoc.company_price || 0) * itemQuantity; - const lineDiscount = (itemAmount * discountPer) / 100 + discountAmt * itemQuantity; - const taxableAmount = itemAmount - lineDiscount; - let taxAmount = 0; - let lineTotal = taxableAmount; - - if (itemTaxRate > 0 && taxType === 'exclusive') { - taxAmount = (taxableAmount * itemTaxRate) / 100; - lineTotal = taxableAmount + taxAmount; - } else if (itemTaxRate > 0 && taxType === 'inclusive') { - lineTotal = taxableAmount; - taxAmount = (lineTotal * itemTaxRate) / (100 + itemTaxRate); - } - - updatedItems.push({ - sale_inline_item_price: sellingPrice, - ...require('../utils/kitchen-amount').forSaleItem(itemDoc, item, sellingPrice), - sale_inline_discount_value: discountAmt, - sale_inline_discount_pervalue: discountPer, - item_discount: lineDiscount, - item_discount_percentage: discountPer, - item_status: 'Add', - return: false, - item_name: itemDoc.name || item.name || '', - ...itemText.snapshot(itemDoc), - item_sku: itemDoc.itemid || '', - item_price: sellingPrice, - item_quantity: itemQuantity, - quantity: itemQuantity, - unit_price: sellingPrice, - item_base_price: sellingPrice, - item_total: lineTotal, - total: lineTotal, - item_tax: taxAmount, - item_available_quantity: parseFloat(itemDoc.available_quantity || 0), - item_id: productId, - ...preparation, - item_unit: itemDoc.unit || 'qty', - total_amount: lineTotal, - barcode_id: itemDoc.barcode_id || '', - company_price_total: companyPriceTotal, - category_id: itemDoc.category_id || null, - category_name: itemDoc.category_name || '', - supplier_id: itemDoc.supplier_id || null, - supplier_name: itemDoc.supplier_name || '', - tax: itemTaxRate, - tax_type: taxType, - igst_tax: 0, - cgst_tax: taxAmount / 2, - sgst_tax: taxAmount / 2, - tax_name: itemDoc.tax_name || '', - tax_amount: taxAmount, - tax_fields: itemDoc.tax_fields || [], - /* - * THE NOTE IS THE WAITER'S, NEVER THE MENU'S. - * - * Owner: "actually we need to show only item name if any - * customization note delibertly captain entered. otherwise dont - * show any other details. dont confuse captain." - * - * This fell back to itemDoc.description, so a dish added with no - * note arrived carrying its menu copy - "slow cooked with 21 - * spices" - which then showed on the handset's live ticket and - * printed in the kitchen as though a waiter had asked for it. - * - * A blank note means nothing was asked for, and blank is the - * honest thing to store. The menu description belongs to the item - * and is one lookup away for anything that genuinely wants it. - */ - item_description: String(item.item_description || ''), - spice_level: spiceLevel.levelOf(item.spice_level), - /* Same reason as the priced line: an added dish keeps the time - the kitchen said it took on the day it was added. */ - prep_minutes: Number(itemDoc.prep_minutes) || 0, - track_inventory: itemDoc.track_inventory || false, - negative_stock: itemDoc.negative_stock || false, - }); - } + const nextLine = { + ...previousLine, + ...amounts, + item_id: productId, + ...preparation, + ...require('../utils/kitchen-amount').forSaleItem(itemDoc, item, pricing.selling_price), + item_name: previousLine?.item_name || itemDoc.name || '', + ...itemText.snapshot(itemDoc), + item_sku: itemDoc.itemid || '', + item_unit: itemDoc.unit || 'qty', + category_id: itemDoc.category_id || null, + category_name: itemDoc.category_name || '', + supplier_id: itemDoc.supplier_id || null, + supplier_name: itemDoc.supplier_name || '', + tax_name: previousLine?.tax_name || itemDoc.tax_name || '', + tax_fields: previousLine?.tax_fields || itemDoc.tax_fields || [], + item_description: String( + item.item_note ?? item.item_description ?? previousLine?.item_description ?? '' + ), + spice_level: spiceLevel.levelOf(item.spice_level ?? previousLine?.spice_level), + prep_minutes: previousLine?.prep_minutes ?? Number(itemDoc.prep_minutes || 0), + modifiers: previousLine?.modifiers || item.modifiers || [], + track_inventory: itemDoc.track_inventory || false, + negative_stock: itemDoc.negative_stock || false, + company_price_total: Number(itemDoc.company_price || 0) * qty, + }; + if (previousLine) updatedItems[existingIndex[lineKey]] = nextLine; + else updatedItems.push({ ...nextLine, item_status: 'Add', return: false }); } for (const remItemData of Object.values(oldItemsData)) { @@ -11050,7 +11013,9 @@ class SalesRepository { itemsSub += parseFloat(it.total_amount || 0); taxTotal += parseFloat(it.tax_amount || 0); itemDiscountTotal += parseFloat(it.item_discount || 0); - grossSubtotal += parseFloat(it.item_quantity || 0) * parseFloat(it.item_price || 0); + grossSubtotal += Number( + it.item_subtotal ?? parseFloat(it.item_quantity || 0) * parseFloat(it.item_price || 0) + ); } const baseSubtotal = grossSubtotal; @@ -11232,7 +11197,7 @@ class SalesRepository { data: { order_id: orderId, items_updated: finalItems.length, - total_amount: totalAmount, + total_amount: updateFields.sales_total, }, } : { @@ -11241,6 +11206,7 @@ class SalesRepository { data: [], }; } catch (error) { + if (error instanceof pricingAuthority.PricingError) return pricingAuthority.failure(error); console.error('Error in updateOrderModel:', error); return { status: false, @@ -11487,124 +11453,44 @@ class SalesRepository { * tax, discount and the inclusive/exclusive arithmetic below all work * on the number the customer was actually shown. */ - /* - * A DISH SOLD AT TODAY'S PRICE. - * - * Owner, after two fish went out at zero on a live table: "zero price - * items are actually dyanmic pricing. its based current price. so if you - * find that kind of item we need to allow captain to update the price and - * give order." - * - * Whole fish, crab, lobster: the shop cannot put a number on the card - * because it does not know one until the morning's market. The catalogue - * carries no selling price, the handset showed 0.00, and the order went to - * the kitchen worth nothing. - * - * THE DOOR OPENS ONLY WHERE THERE IS NO PRICE TO OVERRIDE. Every other - * line is still priced from the catalogue and the client's number is - * ignored, because a caller that can name its own price can buy a biryani - * for one rupee - and the handset is a phone in a pocket, not a trusted - * machine. An item is dynamic when the shop marked it `open_price`, or - * when it simply has no selling price, which is how these are set up - * today. - * - * Refused rather than silently zeroed if the price is missing or absurd: - * a line that reaches the kitchen worth nothing is what started this. - */ - const catalogue = Number(itemDoc.selling_price || 0); - /* - * THE FLAG CONTRACT: daily_price + price_set_on. - * - * A dish marked `daily_price` is priced from the morning's market, and - * `price_set_on` is when somebody last did it. Priced TODAY, it is an - * ordinary dish charged at the catalogue rate - that is the entire point - * of the shop updating it when they open. Priced yesterday, it is not: - * yesterday's rate for a pomfret is not today's, and quietly charging it - * would be worse than the zero this started as, because it would look - * right. - * - * `open_price` is different and stays always-ask: the shop is saying the - * price is settled at the counter, every time. - * - * An item with none of these fields - every shop until the flag ships - - * falls through to "has it got a price at all", which is exactly what it - * did before. - */ - const dynamic = - itemDoc.open_price === true || - (itemDoc.daily_price === true && !this._pricedToday(itemDoc.price_set_on, branchDoc)) || - catalogue <= 0; - /* Three spellings because three callers already exist: the handset's order - payload says `item_price`, a line added to a live order says - `unit_price`, and `price` is what anything hand-written reaches for. */ - const said = [item.unit_price, item.item_price, item.price].find((v) => v != null); - const asked = Number(said); - - if (dynamic) { - if (!Number.isFinite(asked) || asked <= 0) { - return { - status: false, - data: { state: 'item_needs_price', item: itemDoc.name || '' }, - /* - * Worded for whoever is holding the screen, and two different people - * can be: a waiter with the handset, who needs to be told to enter a - * price, and a customer on the self-service page, who cannot be - * asked one and must be sent to a member of staff. The `state` above - * is what an app keys on; this is the sentence a person reads. - */ - message: `${itemDoc.name || 'That dish'} is priced on the day, so it needs today's price. A member of staff can add it.`, - }; - } - /* A ceiling, because a fat finger on a phone is the likeliest way a - wrong number gets here and ten lakh for a fish should not be quietly - accepted. */ - if (asked > 1000000) { - return { - status: false, - data: { state: 'item_price_too_high', item: itemDoc.name || '' }, - message: `${asked} looks wrong for ${itemDoc.name || 'that dish'}. Check the price.`, - }; - } - } - - /* - * THE EXTRAS, ADDED BEFORE TAX AND DISCOUNT RATHER THAN AFTER. - * - * Extra cheese on a pizza is part of the pizza: it is taxed at the - * pizza's rate and a percentage discount applies to the whole plate. Adding - * it afterwards would tax the dish and not the cheese, which is a quiet - * way to file a wrong return. - * - * The delta comes from the shop's own option documents, never from the - * caller. See _priceModifiers. - */ + // Catalogue, configured channel rules and explicit variable-price modes + // are resolved by the same authority used by checkout and amendments. const extras = await this._priceModifiers(item.modifiers, itemDoc, branchDoc); if (extras.status === false) return extras; - - /* A one-off invented at the table: the shop never chose this price on a - card, somebody agreed it with a guest. Same reason as a market price. */ - const oneOff = String(itemDoc.item_status || '').toLowerCase() === 'instant'; - - const sellingPrice = partnerVenues.priceFor( - (dynamic ? asked : catalogue) + extras.delta, - servicePoint.venue - ); - const taxRate = Number(itemDoc.tax || 0); - const discountAmount = Number(itemDoc.discount_amount || 0); + let pricing; + let amounts; + try { + pricing = pricingAuthority.resolve({ + product: itemDoc, + branch: branchDoc, + submitted: item.unit_price ?? item.item_price ?? item.price, + extras: extras.delta, + venue: servicePoint.venue, + channel: where.channel || 'online', + }); + amounts = pricingAuthority.calculate( + pricing, + qty, + branchDoc, + Number(itemDoc.discount_amount || 0), + Number(itemDoc.discount_percentage || 0) + ); + } catch (error) { + return pricingAuthority.failure(error); + } + const dynamic = ['variable', 'quick_item'].includes(pricing.source); + const oneOff = itemDoc.item_status === 'instant'; + const asked = pricing.selling_price; + const catalogue = pricing.selling_price; + const baseUnitPrice = amounts.unit_price; + const taxAmt = amounts.tax_amount; + const itemTotal = amounts.total; + const discountUnit = amounts.item_discount / qty; const discountPercentage = Number(itemDoc.discount_percentage || 0); - const isInclusive = itemDoc.tax_type === 'inclusive'; - const baseUnitPrice = - isInclusive && taxRate > 0 ? sellingPrice / (1 + taxRate / 100) : sellingPrice; - const discountUnit = - discountAmount > 0 ? discountAmount : baseUnitPrice * (discountPercentage / 100); - const taxableUnit = baseUnitPrice - discountUnit; - const taxUnit = taxableUnit * (taxRate / 100); - const finalUnit = isInclusive ? taxableUnit * (1 + taxRate / 100) : taxableUnit + taxUnit; - const taxAmt = round(taxUnit * qty); - const itemTotal = round(finalUnit * qty); return { line: { + ...amounts, item_id: itemId, ...serviceLine.metadata(item), item_name: itemDoc.name || item.item_name || '', @@ -11974,6 +11860,7 @@ class SalesRepository { 'total_amount', 'item_discount', 'company_price_total', + 'item_subtotal', 'cgst_tax', 'sgst_tax', 'igst_tax', @@ -11989,7 +11876,9 @@ class SalesRepository { const unitOf = (l) => Number(l.unit_price != null ? l.unit_price : l.item_price || 0); const lineTotalOf = (l) => Number(l.total != null ? l.total : l.item_total || l.total_amount || 0); - const subtotal = round(lines.reduce((s, l) => s + unitOf(l) * qtyOf(l), 0)); + const subtotal = round( + lines.reduce((s, l) => s + Number(l.item_subtotal ?? unitOf(l) * qtyOf(l)), 0) + ); const tax = round(lines.reduce((s, l) => s + Number(l.tax_amount || l.item_tax || 0), 0)); const itemDiscount = round(lines.reduce((s, l) => s + Number(l.item_discount || 0), 0)); const extra = Number((orderDoc && orderDoc.extra_discount) || 0); diff --git a/api/src/routes/billing-outlets.routes.js b/api/src/routes/billing-outlets.routes.js index 6c28b2159..6730a20a2 100644 --- a/api/src/routes/billing-outlets.routes.js +++ b/api/src/routes/billing-outlets.routes.js @@ -107,7 +107,12 @@ router.post( await collection.updateOne( { ...scope, _id: id }, { - $set: { ...value, updated_at: new Date(), updated_by: req.user._id }, + $set: { + ...value, + updated_at: new Date(), + updated_date: new Date(), + updated_by: req.user._id, + }, $setOnInsert: { ...scope, created_at: new Date() }, }, { upsert: !req.body.id } diff --git a/api/src/services/pricing-authority.js b/api/src/services/pricing-authority.js new file mode 100644 index 000000000..3ea71a75c --- /dev/null +++ b/api/src/services/pricing-authority.js @@ -0,0 +1,267 @@ +'use strict'; + +// Callers supply documents loaded in authenticated scope, never request-owned +// policy objects. Submitted prices are assertions, not configuration. +const Money = require('../utils/currency'); +const tradingDay = require('../utils/trading-day'); +const venues = require('../utils/partner-venues'); + +class PricingError extends Error { + constructor(code, message, details = {}) { + super(message); + this.code = code; + this.statusCode = 409; + this.details = { state: code, ...details }; + } +} +const fail = (code, message, details) => { + throw new PricingError(code, message, details); +}; +function number(value, label) { + if ( + value === '' || + value === null || + typeof value === 'boolean' || + typeof value === 'object' || + !Number.isFinite(Number(value)) + ) + fail('invalid_price', `${label} must be a finite number.`); + return Number(value); +} +function isVariable(product, branch) { + return ( + product.open_price === true || + product.item_status === 'instant' || + (product.daily_price === true && !tradingDay.isToday(product.price_set_on, branch)) || + Number(product.selling_price ?? product.item_price ?? 0) <= 0 + ); +} +function resolve({ + product, + branch = {}, + submitted, + previous, + extras = 0, + venue, + outlet, + priceList, + channel = 'counter', +}) { + const monetary = Money.policy(branch); + const round = (value) => Money.fromMinor(Money.toMinor(value, monetary), monetary); + const name = product.name || product.item_name || 'Item'; + const old = previous?.pricing; + // Only a stored snapshot may preserve a historical price. Clients cannot + // install one: adapters read previous from the persisted order. + if (old?.version === 1) { + if (String(old.item_id) !== String(product._id)) + fail('price_context_mismatch', 'Item pricing context changed.'); + const expected = number(old.selling_price, 'Stored price'); + assertPrice(submitted, expected, round, name); + return { ...old }; + } + const catalogue = number(product.selling_price ?? product.item_price ?? 0, 'Catalogue price'); + const variable = isVariable(product, branch); + let selling = catalogue; + let source = 'catalogue'; + let ruleId = null; + if (variable) { + if (product.item_status === 'instant' && submitted === undefined && catalogue > 0) + submitted = catalogue; + if ( + submitted === undefined || + submitted === null || + submitted === '' || + !Number.isFinite(Number(submitted)) || + Number(submitted) <= 0 + ) + fail('item_needs_price', `${name} is priced on the day, so it needs a valid entered price.`); + selling = number(submitted, 'Entered price'); + source = product.item_status === 'instant' ? 'quick_item' : 'variable'; + } else if (outlet) { + const override = outlet.prices?.find((p) => String(p.item_id) === String(product._id)); + selling = override + ? number(override.price, 'Outlet price') + : round(catalogue * (1 + number(outlet.markup_percent ?? 0, 'Outlet markup') / 100)); + source = 'outlet'; + ruleId = String(outlet.id || outlet._id); + } else if (priceList) { + const override = priceList.item_overrides?.find( + (p) => String(p.item_id) === String(product._id) + ); + selling = override + ? number(override.price, 'Price list price') + : round(catalogue * (1 - number(priceList.percent_off ?? 0, 'Price list discount') / 100)); + source = 'price_list'; + ruleId = String(priceList._id); + } + selling += number(extras, 'Modifier price'); + if (venue) { + selling = venues.priceFor(selling, venue); + source = 'venue'; + ruleId = venue.code; + } + selling = round(selling); + if (selling < 0 || (variable && selling > 1000000)) + fail('item_price_too_high', `${name}: price is outside the allowed range.`); + if (!variable) assertPrice(submitted, selling, round, name); + const tax = number(product.tax ?? 0, 'Tax rate'); + const taxType = product.tax_type || 'exclusive'; + if (tax < 0 || tax > 100 || !['inclusive', 'exclusive'].includes(taxType)) + fail('invalid_tax_configuration', `${name}: invalid catalogue tax configuration.`); + return { + version: 1, + item_id: String(product._id), + source, + rule_id: ruleId, + channel, + selling_price: selling, + tax, + tax_type: taxType, + currency: monetary, + catalogue_price: catalogue, + modifier_delta: extras, + rule_revision: outlet?.updated_at || priceList?.updated_date || null, + venue_adjust_percent: venue?.price_adjust_percent || 0, + catalogue_revision: product.updated_date || null, + }; +} +function assertPrice(submitted, expected, round, name) { + if (submitted === undefined) return; + const actual = number(submitted, 'Submitted price'); + if (round(actual) !== round(expected)) + fail( + 'item_price_mismatch', + `${name}: price changed or does not match this selling channel. Refresh the menu before saving.`, + { item: name, expected_price: round(expected), submitted_price: actual } + ); +} +function failure(error) { + if (!(error instanceof PricingError)) throw error; + return { status: false, message: error.message, data: error.details }; +} +function calculate(pricing, quantity, branch = {}, discountAmount = 0, discountPercentage = 0) { + const qty = number(quantity, 'Quantity'); + if (qty <= 0) fail('invalid_quantity', 'Quantity must be greater than zero.'); + const monetary = Money.policy(pricing.currency || branch); + const round = (value) => Money.fromMinor(Money.toMinor(value, monetary), monetary); + const result = require('./tax-engine').computeLineTax({ + sellingPrice: pricing.selling_price, + itemAmount: pricing.selling_price * qty, + itemQuantity: qty, + itemTax: pricing.tax, + taxType: pricing.tax_type, + discountAmount, + discountPercentage, + gstAmount: 0, + }); + const base = + pricing.tax_type === 'inclusive' + ? pricing.selling_price / (1 + pricing.tax / 100) + : pricing.selling_price; + const tax = round(result.tax); + const total = round(result.total); + return { + pricing, + unit_price: round(base), + item_price: round(base), + item_base_price: round(base), + item_subtotal: round(result.subtotal), + sale_inline_item_price: pricing.selling_price, + quantity: qty, + item_quantity: qty, + total, + item_total: total, + total_amount: total, + tax: pricing.tax, + tax_type: pricing.tax_type, + tax_amount: tax, + item_tax: tax, + cgst_tax: round(tax / 2), + sgst_tax: round(tax - round(tax / 2)), + item_discount: round(result.discount), + item_discount_percentage: discountPercentage, + sale_inline_discount_value: discountAmount, + sale_inline_discount_pervalue: discountPercentage, + }; +} +async function loadContext(db, order) { + const { ObjectId } = require('mongodb'); + const scope = { license: order.license, branch_id: order.branch_id }; + const result = {}; + if (order.venue?.venue_code) { + const settings = await db + .collection('settings') + .findOne({ license: order.license, partner_venues: { $exists: true } }); + result.venue = venues.venueByCode(order.venue.venue_code, settings?.partner_venues || []); + if (!result.venue) + fail( + 'pricing_rule_unavailable', + 'Venue pricing is unavailable on this server. Sync settings before adding items.' + ); + } + if (order.outlet_id) { + result.outlet = await db + .collection('billing_outlets') + .findOne({ ...scope, _id: new ObjectId(String(order.outlet_id)) }); + if (!result.outlet || result.outlet.active === false) + fail( + 'pricing_rule_unavailable', + 'Outlet pricing is unavailable on this server. Sync settings before adding items.' + ); + } + if (order.customer_id && ObjectId.isValid(String(order.customer_id))) { + const customer = await db + .collection('customers') + .findOne({ license: order.license, _id: new ObjectId(String(order.customer_id)) }); + if (customer?.category_id) + result.priceList = await db + .collection('price_lists') + .findOne({ ...scope, customer_category_id: String(customer.category_id) }); + } + return result; +} +function menuQuote(product, branch = {}, venue) { + if (isVariable(product, branch)) { + const indicative = venues.priceFor( + number(product.selling_price ?? 0, 'Catalogue price'), + venue + ); + const amounts = calculate( + { + selling_price: indicative, + tax: Number(product.tax || 0), + tax_type: product.tax_type || 'exclusive', + }, + 1, + branch + ); + return { + price: indicative, + final_price: amounts.total, + price_mode: 'variable', + price_basis: 'selling_price', + quote_required: true, + }; + } + const pricing = resolve({ product, branch, venue }); + const line = calculate( + pricing, + 1, + branch, + Number(product.discount_amount || 0), + Number(product.discount_percentage || 0) + ); + return { + price: pricing.selling_price, + final_price: line.total, + discount_price: line.item_discount, + tax_price: pricing.tax_type === 'exclusive' ? line.tax_amount : 0, + tax: pricing.tax, + tax_type: pricing.tax_type, + price_mode: 'fixed', + price_basis: 'selling_price', + quote_required: false, + }; +} +module.exports = { resolve, calculate, assertPrice, PricingError, failure, loadContext, menuQuote }; diff --git a/api/src/services/sale.service.js b/api/src/services/sale.service.js index ae84c6ea4..ce36a6bf2 100644 --- a/api/src/services/sale.service.js +++ b/api/src/services/sale.service.js @@ -1,3 +1,4 @@ +const pricingAuthority = require('./pricing-authority'); const mongoose = require('mongoose'); const { ObjectId } = require('mongodb'); const Sale = require('../models/sale.model'); @@ -239,6 +240,38 @@ const processSale = async ( return { status: false, message: ERROR_MESSAGES.BRANCH_LICENSE_REQUIRED }; } + const savedAnswer = (saleId, saleNumber, duplicate = false) => ({ + status: true, + data: { + _id: saleId, + sales_id: saleId, + sale_number: saleNumber, + sms: context.branchSettings?.sales_sms || false, + whatsapp: context.branchSettings?.whatsapp_receipt || false, + print: context.branchSettings?.printall || false, + mail: context.branchSettings?.sales_mail || false, + waring: 'success', + name: (data.customer_name || '').trim(), + phone: (data.customer_phone || '').trim(), + customer_balance: duplicate ? 0 : customer ? customer.balance || 0 : 0, + country_sort: context.branchSettings?.sortname || 'in', + ...(duplicate ? { duplicate: true } : {}), + }, + message: 'Sale saved successfully', + }); + + // A retry returns its committed result before current catalogue changes + // can invalidate the original, already accepted request. + if (!preview && id === '' && data.idempotencyKey) { + const existing = await require('./desktop-submission').lookup( + await BaseModel.getDb(), + { branchId, license: licenseId }, + String(userId || ''), + data + ); + if (existing) return savedAnswer(existing._id, existing.sales_id, true); + } + // 2. Check Plan / Max Sales (Skipped for now, assuming valid plan) // In strict PHP parity, we would check collection count. @@ -332,6 +365,19 @@ const processSale = async ( existingSale, data.outlet_revision ); + const pricingBranch = context.branchSettings || {}; + const pricingCustomer = data.customer_id + ? await customerRepository.findById(data.customer_id) + : null; + let priceList; + if (pricingCustomer?.category_id) { + const pricingDb = await BaseModel.getDb(); + priceList = await pricingDb.collection('price_lists').findOne({ + branch_id: branchId, + license: licenseId, + customer_category_id: String(pricingCustomer.category_id), + }); + } const itemsale = []; const total_available_qty_map = {}; // To track simulated deductions within this batch @@ -375,26 +421,54 @@ const processSale = async ( parseLegacyNumber(document.discount_percentage) ?? 0; - // Selling Price Resolution - let sellingPrice = - parseLegacyNumber(item.sale_inline_item_price) ?? - parseLegacyNumber(item.item_price_total) ?? - parseLegacyNumber(document.selling_price) ?? - 0; - - if (outlet && !existingSale) { - const extras = (Array.isArray(item.modifiers) ? item.modifiers : []).reduce( - (sum, m) => sum + (Number(m.price_delta) || 0), - 0 - ); - sellingPrice = billingOutlets.price(outlet, document, sellingPrice); - if ( - document.open_price !== true && - Number(document.selling_price) > 0 && - document.item_status !== 'instant' - ) - sellingPrice += extras; + const previousLine = existingSale?.items?.find( + (line) => String(line.line_id || line.item_id) === String(item.line_id || itemId) + ); + if ( + previousLine?.pricing && + item.modifiers !== undefined && + JSON.stringify(item.modifiers) !== JSON.stringify(previousLine.modifiers || []) + ) + return { + status: false, + message: 'Add a separate line to change priced modifiers.', + data: { state: 'item_modifiers_changed' }, + }; + const extras = previousLine?.pricing + ? { delta: previousLine.pricing.modifier_delta || 0, lines: previousLine.modifiers || [] } + : item.modifiers?.length + ? await salesRepository._priceModifiers(item.modifiers, document, { + ...pricingBranch, + license: licenseId, + }) + : { delta: 0, lines: [] }; + if (extras.status === false) return extras; + let pricing; + try { + pricing = pricingAuthority.resolve({ + product: document, + branch: pricingBranch, + previous: previousLine, + submitted: item.sale_inline_item_price ?? item.item_price_total, + extras: extras.delta, + outlet, + priceList, + channel: existingSale?.channel || data.channel || 'counter', + }); + if (item.tax != null && Number(item.tax) !== pricing.tax) + throw new pricingAuthority.PricingError( + 'item_tax_mismatch', + 'Tax changed. Refresh the item before saving.' + ); + if (item.tax_type && item.tax_type !== pricing.tax_type) + throw new pricingAuthority.PricingError( + 'item_tax_mismatch', + 'Tax type changed. Refresh the item before saving.' + ); + } catch (error) { + return pricingAuthority.failure(error); } + const sellingPrice = pricing.selling_price; const itemAmount = sellingPrice * itemQuantity; // Prefer payload-provided company_price_total when available, otherwise @@ -405,11 +479,8 @@ const processSale = async ( total_company_data.push({ company_amount: companyPrice }); - // Tax percentage: prefer per-line tax rate from payload (item.tax), - // then fall back to the item master tax when not provided. The - // separate GST amount from the legacy frontend is handled via - // `gstValue` below. - const itemTax = parseLegacyNumber(item.tax) ?? parseLegacyNumber(document.tax) ?? 0; + // The validated catalogue/snapshot is the only tax-rate authority. + const itemTax = pricing.tax; let itemDiscountAmountMultiple = 0; let itemDiscountPercentageMultiple = 0; @@ -417,7 +488,7 @@ const processSale = async ( let itemSubTaxTotalCalculation = 0; let itemTaxAmountForItem = 0; let effectiveItemTax = itemTax; - let effectiveTaxType = item.tax_type || document.tax_type || ''; + let effectiveTaxType = pricing.tax_type; /* * T1: the ONE tax engine computes this line. tax-engine.js carries @@ -429,14 +500,13 @@ const processSale = async ( * value, and only the legacy GST-amount fallback may override the * item's declared tax rate and type. */ - const gstAmountForFallback = - typeof item.gst === 'number' ? item.gst : parseFloat(item.gst || 0) || 0; + const gstAmountForFallback = 0; const engineLine = computeLineTax({ itemAmount, sellingPrice, itemQuantity, itemTax, - taxType: document.tax_type, + taxType: pricing.tax_type, discountAmount, discountPercentage, gstAmount: gstAmountForFallback, @@ -515,19 +585,11 @@ const processSale = async ( let igst_value = 0; let csgst_value = 0; - // In PHP, $gstValue comes from the incoming item payload (per-line GST), - // not from the calculated tax amount. However, some modern flows may - // omit this dedicated `gst` field and only send percentage + totals. - // To keep the sales view Tax Details card correct in those cases, we - // fall back to the server-computed per-line tax amount when needed. - const gstValueRaw = typeof item.gst === 'number' ? item.gst : parseFloat(item.gst || 0) || 0; - + // Tax components follow the server calculation, never submitted GST amounts. const fallbackGst = typeof itemTaxAmountForItem === 'number' ? itemTaxAmountForItem : 0; - // Prefer explicit payload gst when provided; otherwise, reuse the - // computed tax amount so IGST/CGST/SGST are still populated for - // GST-enabled branches. - const gstValue = gstValueRaw > 0 ? gstValueRaw : fallbackGst; + // Populate GST components from the accepted line calculation. + const gstValue = fallbackGst; const indianGstSetting = context.branchSettings?.indian_gst || 'gst_off'; @@ -604,12 +666,18 @@ const processSale = async ( // distinguish HSN-based tax rows from simple one-rate taxes when // Indian GST is disabled. itemsale.push({ + pricing, + ...require('../utils/order-line').identity(item), + ...(extras.lines.length ? { modifiers: extras.lines } : {}), // Mongoose Schema Required Fields (Node-native) item: new ObjectId(itemId), name: document.name, quantity: itemQuantity, ...require('../utils/kitchen-amount').forSaleItem(document, item, sellingPrice), - unit_price: sellingPrice, + unit_price: + pricing.tax_type === 'inclusive' ? sellingPrice / (1 + pricing.tax / 100) : sellingPrice, + item_base_price: + pricing.tax_type === 'inclusive' ? sellingPrice / (1 + pricing.tax / 100) : sellingPrice, tax_rate: effectiveItemTax, // tax_amount is defined below in the PHP-legacy block to avoid // duplicate keys and to mirror PHP's stored per-line tax amount. @@ -1021,7 +1089,7 @@ const processSale = async ( // Customer // In Node we don't fetch and store full customer object redundantly usually, but PHP does. // We will stick to schema which has separate fields for customer_* - const customer = data.customer_id ? await customerRepository.findById(data.customer_id) : null; + const customer = pricingCustomer; // Insert block: mirror PHP $insertData field order as closely as possible. const insertData = { @@ -1296,25 +1364,6 @@ const processSale = async ( denomination_values: data.denomination_values ?? (existingSale?.denomination_values || []), }; - const savedAnswer = (saleId, saleNumber, duplicate = false) => ({ - status: true, - data: { - _id: saleId, - sales_id: saleId, - sale_number: saleNumber, - sms: context.branchSettings?.sales_sms || false, - whatsapp: context.branchSettings?.whatsapp_receipt || false, - print: context.branchSettings?.printall || false, - mail: context.branchSettings?.sales_mail || false, - waring: 'success', - name: (data.customer_name || '').trim(), - phone: (data.customer_phone || '').trim(), - customer_balance: customer ? customer.balance || 0 : 0, - country_sort: context.branchSettings?.sortname || 'in', - ...(duplicate ? { duplicate: true } : {}), - }, - message: 'Sale saved successfully', - }); const finalSaleData = id === '' ? { ...insertData, ...updateData } : updateData; if (finalSaleData.kitchen_required) { finalSaleData.kitchen_closed = !require('../helpers/kitchen-rounds') diff --git a/api/src/sync/collections.json b/api/src/sync/collections.json index 38f116465..63b5f71e3 100644 --- a/api/src/sync/collections.json +++ b/api/src/sync/collections.json @@ -34,7 +34,10 @@ "unit": "global", "branches": "global", "users": "global", - "settings": "global" + "settings": "global", + "billing_outlets": "branch", + "price_lists": "branch", + "modifier_groups": "branch" }, "local_only": { "captain_email_verifications": "One-use email verification challenges, attempt counters and expiry belong to the issuing account server; replication would permit replay.", @@ -111,7 +114,6 @@ "branch_preferences": "per-branch preferences set in one place do not reach the other", "transaction": "customer credit transactions on the till are unknown to the cloud", "payment": "payment records on the till are unknown to the cloud", - "purchase_orders": "a purchase order raised on the till is unknown to the cloud, and the reverse; receivings already sync", - "billing_outlets": "Outlet configuration is currently scoped to its API database; cloud-to-desktop configuration sync is not yet implemented. Sales retain outlet snapshots." + "purchase_orders": "a purchase order raised on the till is unknown to the cloud, and the reverse; receivings already sync" } } diff --git a/api/tests/unit/models/sale.model.test.js b/api/tests/unit/models/sale.model.test.js index 062a63ac8..4bb1243f4 100644 --- a/api/tests/unit/models/sale.model.test.js +++ b/api/tests/unit/models/sale.model.test.js @@ -1015,6 +1015,35 @@ describe('invoice_key survives the strict schema', () => { }); describe('item translation snapshots', () => { + test('the accepted pricing snapshot survives legacy live-order normalization', async () => { + const pricing = { + version: 1, + item_id: 'water', + source: 'catalogue', + selling_price: 30, + tax: 5, + tax_type: 'inclusive', + }; + const sale = new Sale({ + sale_method: 'Live-Order', + items: [ + { + item_name: 'Water', + item_quantity: 1, + total_amount: 30, + pricing, + unit_price: 28.57, + item_base_price: 28.57, + }, + ], + }); + await getPreSaveFn().call(sale); + expect(sale.toObject().items[0]).toMatchObject({ + pricing, + unit_price: 28.57, + item_base_price: 28.57, + }); + }); test('Mongoose and live-order normalization retain names without menu descriptions', async () => { const sale = new Sale({ sale_method: 'Live-Order', diff --git a/api/tests/unit/repositories/a-dish-priced-on-the-day.test.js b/api/tests/unit/repositories/a-dish-priced-on-the-day.test.js index 524d2dfc5..0e8846863 100644 --- a/api/tests/unit/repositories/a-dish-priced-on-the-day.test.js +++ b/api/tests/unit/repositories/a-dish-priced-on-the-day.test.js @@ -275,8 +275,7 @@ describe('the daily_price flag', () => { unit_price: 1, }); - expect(out.status).not.toBe(false); - expect(out.line.unit_price).toBe(900); + expect(out).toMatchObject({ status: false, data: { state: 'item_price_mismatch' } }); }); test("priced YESTERDAY, yesterday's rate is not charged as today's", async () => { @@ -368,7 +367,7 @@ describe('the daily_price flag', () => { }); describe('an ordinary dish', () => { - test('IGNORES a price the client sent', async () => { + test('REJECTS an unexplained price the client sent', async () => { /* * The hole this must not open. The handset is a phone in somebody's * pocket; a caller that can name its own price can buy a biryani for one @@ -383,8 +382,7 @@ describe('an ordinary dish', () => { unit_price: 1, }); - expect(out.line.unit_price).toBe(220); - expect(out.line.total).toBe(440); + expect(out).toMatchObject({ status: false, data: { state: 'item_price_mismatch' } }); }); test('is unaffected when no price is sent at all', async () => { diff --git a/api/tests/unit/repositories/order-pricing-authority.test.js b/api/tests/unit/repositories/order-pricing-authority.test.js new file mode 100644 index 000000000..f96cde4a3 --- /dev/null +++ b/api/tests/unit/repositories/order-pricing-authority.test.js @@ -0,0 +1,146 @@ +'use strict'; +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); +const repo = require('../../../src/repositories/sale.repository'); +const BaseModel = require('../../../src/models/base.model'); +let mem, db; +const branch = new mongoose.Types.ObjectId(); +const license = new mongoose.Types.ObjectId(); +let product, order; +beforeAll(async () => { + mem = await MongoMemoryServer.create(); + await mongoose.connect(mem.getUri()); + db = mongoose.connection.db; +}, 60000); +afterAll(async () => { + await mongoose.disconnect(); + await mem.stop(); +}); +beforeEach(async () => { + jest.spyOn(BaseModel, 'getDb').mockResolvedValue(db); + jest.spyOn(console, 'error').mockImplementation(() => {}); + for (const collection of ['branches', 'items', 'sales']) + await db.collection(collection).deleteMany({}); + await db.collection('branches').insertOne({ _id: branch, license }); + product = { + _id: new mongoose.Types.ObjectId(), + branch_id: branch, + license, + name: 'Paratha', + selling_price: 45, + tax: 5, + tax_type: 'exclusive', + }; + await db.collection('items').insertOne(product); + order = { + _id: new mongoose.Types.ObjectId(), + branch_id: branch, + license, + sale_process: 'KOT', + payment_status: 'Unpaid', + items: [], + changes: [], + updated_date: new Date(), + }; + await db.collection('sales').insertOne(order); +}); +afterEach(() => jest.restoreAllMocks()); +const save = (items) => + repo.updateOrderModel( + String(order._id), + items, + 99999, + 'modified', + null, + null, + null, + undefined, + undefined, + undefined + ); +const line = (price, qty = 2) => ({ product_id: String(product._id), price, quantity: qty }); +const stored = () => db.collection('sales').findOne({ _id: order._id }); + +test('the production double-tax submission is rejected without changing the order', async () => { + expect(await save([line(47.25)])).toMatchObject({ + status: false, + data: { state: 'item_price_mismatch' }, + }); + expect(await stored()).toEqual(order); +}); +test('request-owned variable flags and pricing snapshots cannot authorize an override', async () => { + expect( + (await save([{ ...line(100), open_price: true, pricing: { version: 1, selling_price: 100 } }])) + .status + ).toBe(false); + expect(await stored()).toEqual(order); +}); +test('new fixed items get one tax calculation and authoritative totals', async () => { + expect(await save([line(45)])).toMatchObject({ status: true }); + expect(await stored()).toMatchObject({ + sales_sub_total: 90, + tax: 4.5, + sales_total: 94.5, + items: [ + expect.objectContaining({ + unit_price: 45, + total: 94.5, + pricing: expect.objectContaining({ source: 'catalogue' }), + }), + ], + }); +}); +test('a bad second line cannot partially add the first', async () => { + expect( + ( + await save([ + { ...line(45), line_id: 'first' }, + { ...line(47.25), line_id: 'second' }, + ]) + ).status + ).toBe(false); + expect(await stored()).toEqual(order); +}); +test('existing snapshots preserve agreed tax and price but reject a submitted override', async () => { + expect((await save([line(45)])).status).toBe(true); + await db + .collection('items') + .updateOne({ _id: product._id }, { $set: { selling_price: 60, tax: 18 } }); + expect((await save([line(45, 3)])).status).toBe(true); + expect(await stored()).toMatchObject({ sales_sub_total: 135, tax: 6.75, sales_total: 141.75 }); + const before = await stored(); + expect((await save([line(60, 3)])).status).toBe(false); + expect(await stored()).toEqual(before); +}); +test('inclusive new and existing lines never accumulate tax', async () => { + await db + .collection('items') + .updateOne({ _id: product._id }, { $set: { selling_price: 30, tax_type: 'inclusive' } }); + expect((await save([line(30)])).status).toBe(true); + expect(await stored()).toMatchObject({ sales_total: 60, tax: 2.86 }); + const current = (await stored()).items[0]; + expect((await save([{ ...current, price: current.unit_price, quantity: 3 }])).status).toBe(true); + expect(await stored()).toMatchObject({ sales_total: 90, tax: 4.29 }); +}); +test('a legacy inflated line cannot silently acquire a validated snapshot', async () => { + await db.collection('sales').updateOne( + { _id: order._id }, + { + $set: { + items: [{ item_id: product._id, item_quantity: 2, item_price: 47.25, unit_price: 47.25 }], + }, + } + ); + expect((await save([line(47.25)])).status).toBe(false); + expect((await stored()).items[0].pricing).toBeUndefined(); +}); +test('variable and quick-item prices are accepted only from catalogue-marked products', async () => { + for (const mode of [{ open_price: true }, { item_status: 'instant' }]) { + await db.collection('items').updateOne({ _id: product._id }, { $set: mode }); + await db + .collection('sales') + .updateOne({ _id: order._id }, { $set: { items: [], changes: [] } }); + expect((await save([line(100, 1)])).status).toBe(true); + expect(await stored()).toMatchObject({ sales_total: 105 }); + } +}); diff --git a/api/tests/unit/services/pricing-authority.test.js b/api/tests/unit/services/pricing-authority.test.js new file mode 100644 index 000000000..223e53972 --- /dev/null +++ b/api/tests/unit/services/pricing-authority.test.js @@ -0,0 +1,92 @@ +'use strict'; +const pricing = require('../../../src/services/pricing-authority'); +const product = { + _id: 'paratha', + name: 'Paratha', + selling_price: 45, + tax: 5, + tax_type: 'exclusive', +}; +const quote = (changes = {}) => pricing.resolve({ product, ...changes }); + +test('rejects the exact double-tax receipt input and never silently substitutes a price', () => { + expect(() => quote({ submitted: 47.25 })).toThrow(/does not match/); + expect(pricing.calculate(quote({ submitted: 45 }), 2)).toMatchObject({ + unit_price: 45, + total: 94.5, + tax_amount: 4.5, + }); +}); +test('inclusive water stays 30 including tax, including after quantity changes', () => { + const q = quote({ + product: { ...product, selling_price: 30, tax_type: 'inclusive' }, + submitted: 30, + }); + expect(pricing.calculate(q, 2)).toMatchObject({ total: 60, tax_amount: 2.86 }); +}); +test('a channel label does not authorize an arbitrary price', () => { + expect(() => quote({ channel: 'online', submitted: 60 })).toThrow(/does not match/); +}); +test('configured outlet and customer prices are enforced', () => { + expect(quote({ outlet: { id: 'room', markup_percent: 20 }, submitted: 54 }).source).toBe( + 'outlet' + ); + expect(() => quote({ outlet: { id: 'room', markup_percent: 20 }, submitted: 45 })).toThrow(); + expect( + quote({ priceList: { _id: 'wholesale', percent_off: 10 }, submitted: 40.5 }).selling_price + ).toBe(40.5); +}); +test('stored snapshots preserve agreed price and tax after catalogue edits', () => { + const previous = { pricing: quote() }; + expect( + quote({ product: { ...product, selling_price: 60, tax: 18 }, previous, submitted: 45 }) + ).toEqual(previous.pricing); + expect(() => quote({ previous, submitted: 1 })).toThrow(); +}); +test.each(['variable', 'quick'])('%s is explicit and bounded', (kind) => { + const p = { + ...product, + ...(kind === 'quick' ? { item_status: 'instant' } : { open_price: true }), + }; + expect(quote({ product: p, submitted: 250 }).selling_price).toBe(250); + for (const invalid of [ + ...(kind === 'quick' ? [] : [undefined]), + '', + null, + -1, + 0, + NaN, + Infinity, + 1000001, + true, + {}, + ]) + expect(() => quote({ product: p, submitted: invalid })).toThrow(); +}); +test('modifiers are part of the validated price before tax', () => { + const q = quote({ extras: 10, submitted: 55 }); + expect(pricing.calculate(q, 2)).toMatchObject({ total: 115.5, tax_amount: 5.5 }); +}); +test('unknown venue configuration on a local server fails closed', async () => { + const db = { collection: () => ({ findOne: async () => null }) }; + await expect(pricing.loadContext(db, { venue: { venue_code: 'missing' } })).rejects.toMatchObject( + { code: 'pricing_rule_unavailable' } + ); +}); + +test('menu quote and admission share exclusive/inclusive/venue/discount arithmetic', () => { + for (const tax_type of ['exclusive', 'inclusive']) { + const p = { ...product, selling_price: 130, tax_type, discount_amount: 10 }; + const venue = { code: 'hotel', price_adjust_percent: 20 }; + const menu = pricing.menuQuote(p, {}, venue); + const accepted = pricing.resolve({ product: p, submitted: menu.price, venue }); + expect(pricing.calculate(accepted, 1, {}, 10).total).toBe(menu.final_price); + expect(menu.price_basis).toBe('selling_price'); + } +}); +test('inclusive quantity subtotal reconciles even when the rounded unit does not multiply exactly', () => { + const p = quote({ product: { ...product, selling_price: 30, tax_type: 'inclusive' } }); + const line = pricing.calculate(p, 99); + expect(line.total).toBe(2970); + expect(line.item_subtotal + line.tax_amount).toBeCloseTo(line.total, 2); +}); diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js index 5952e387c..e26185e73 100644 --- a/api/tests/unit/services/sale.service.test.js +++ b/api/tests/unit/services/sale.service.test.js @@ -190,6 +190,29 @@ describe('SalesService', () => { consoleWarnSpy.mockRestore(); }); + describe('fixed-price admission', () => { + test.each([ + [{ item_price_total: '47.25' }, 'item_price_mismatch'], + [{ item_price_total: '45', tax: 18 }, 'item_tax_mismatch'], + [{ item_price_total: '45', tax: 5, tax_type: 'inclusive' }, 'item_tax_mismatch'], + ])('rejects a forged price or tax before sale and stock writes: %j', async (payload, state) => { + mockItemRepositoryInstance.findItemById.mockResolvedValue( + makeItemDoc({ selling_price: 45, tax: 5, tax_type: 'exclusive' }) + ); + const result = await salesService.processSale( + makeSaleData({ items: [makeItemPayload(payload)] }), + '', + 'Add', + makeContext() + ); + expect(result).toMatchObject({ status: false, data: { state } }); + expect(salesRepository.create).not.toHaveBeenCalled(); + expect(salesRepository.save).not.toHaveBeenCalled(); + expect(mockItemRepositoryInstance.deductStockIfAvailable).not.toHaveBeenCalled(); + expect(mockItemRepositoryInstance.updateStock).not.toHaveBeenCalled(); + }); + }); + describe('tax-inclusive full-payment integrity', () => { const taxableSale = (multi_payment, overrides = {}) => makeSaleData({ @@ -456,7 +479,9 @@ describe('SalesService', () => { test('a bill-level decision binds the actual checkout preview and explains rounding in exact minor units', async () => { const { prepareDiscountIntent } = require('../../../src/services/business-discount-intent'); - mockItemRepositoryInstance.findItemById.mockResolvedValue(makeItemDoc()); + mockItemRepositoryInstance.findItemById.mockResolvedValue( + makeItemDoc({ selling_price: 100.27 }) + ); const context = makeContext({ roundOff: true, branchSettings: { @@ -503,7 +528,7 @@ describe('SalesService', () => { ).revisionHash ).not.toBe(prepared.revisionHash); mockItemRepositoryInstance.findItemById.mockResolvedValue( - makeItemDoc({ tax: 10, tax_type: 'exclusive' }) + makeItemDoc({ selling_price: 100.27, tax: 10, tax_type: 'exclusive' }) ); expect( (await prepareDiscountIntent(data, context, 'Regular customer')).revisionHash @@ -671,6 +696,7 @@ describe('SalesService', () => { }, { name: 'bill rounding is already applied', + item: { selling_price: 100.24 }, data: { items: [makeItemPayload({ item_price_total: '100.24' })] }, context: { roundOff: true }, expected: 20000, @@ -709,7 +735,8 @@ describe('SalesService', () => { // ── processSale – validation ────────────────────────────────────────────── test('a recovered desktop submission returns its sale before stock is deducted again', async () => { - require('../../../src/services/desktop-submission').prepare.mockResolvedValueOnce({ + mockItemRepositoryInstance.findItemById.mockResolvedValue(makeItemDoc()); + require('../../../src/services/desktop-submission').lookup.mockResolvedValueOnce({ _id: 'saved-sale', sales_id: 'INV-SAVED', }); @@ -829,7 +856,10 @@ describe('SalesService', () => { .mockResolvedValue(outlet); try { const result = await salesService.processSale( - makeSaleData({ outlet_id: outlet.id }), + makeSaleData({ + outlet_id: outlet.id, + items: [makeItemPayload({ item_price_total: '125' })], + }), '', 'Add', makeContext() @@ -877,7 +907,10 @@ describe('SalesService', () => { }); try { const result = await salesService.processSale( - makeSaleData({ outlet_expected_total: 200 }), + makeSaleData({ + outlet_expected_total: 200, + items: [makeItemPayload({ item_price_total: '125' })], + }), '', 'Add', makeContext() diff --git a/docs/AUTHORITATIVE_ORDER_PRICING.md b/docs/AUTHORITATIVE_ORDER_PRICING.md new file mode 100644 index 000000000..4312c9716 --- /dev/null +++ b/docs/AUTHORITATIVE_ORDER_PRICING.md @@ -0,0 +1,70 @@ +# Authoritative order pricing + +Status: implementation for review; coordinated POS API, desktop, Captain and Gateway release required. + +## Problem and invariant + +Azure receipt SB1D28-27-000130 exposed a price-contract failure. Captain's existing-order search took `final_price` (45 plus 5% = 47.25), submitted it as `price`, and the amendment API treated it as an exclusive base. Original order creation used catalogue prices; amendments trusted the request. Receipt rendering accurately reproduced an incorrectly priced stored order. + +**A client chooses products, quantities and permitted options. It does not establish a fixed product's price, tax rate or tax treatment.** A stated price is an assertion which must match the issuing API's rules. A different price requires a configured rule or an explicitly variable product. An arbitrary channel label is not a pricing rule. + +## Pattern: domain pricing authority with adapters + +`api/src/services/pricing-authority.js` is the shared admission policy used by cloud and the API bundled into desktop. The pure resolver accepts scoped catalogue/configuration documents and produces a versioned pricing snapshot. `calculate` calls the existing `tax-engine` for line arithmetic. Adapters translate legacy request field names; they must not decide whether an override is permitted. + +| Adapter | Responsibility | +| --- | --- | +| Catalogue/menu response | Produce `price` and `final_price` through `menuQuote`; label their basis | +| Online/Captain order creation | Resolve item, validate submitted price, price server-owned modifiers, calculate line | +| Existing-order amendment | Load existing line from storage, validate input against that line, preserve its accepted snapshot; resolve new lines from current rules | +| Desktop checkout | Resolve catalogue/outlet/customer rules, validate submitted price and tax, retain existing permission checks for discounts and payment | +| Kiosk | Apply the same fixed/variable policy before using the shared tax engine | +| Receipt/payment | Consume accepted line values; never turn a displayed total back into a base price | +| Sync | Transport committed records and configuration; never recalculate or silently rewrite historical sales | + +No request-owned `pricing`, `open_price`, tax setting, venue markup or price-list document is authoritative. Existing snapshots are read from the persisted sale, not copied from the request. + +## Price and tax contract + +`selling_price` is in the catalogue's tax basis: before tax for exclusive products, including tax for inclusive products. Menu `price` has this basis. Menu `final_price` is for display and includes the applicable discount and tax. It must never be submitted as the selling price. + +Canonical order calculations keep before-tax `unit_price`/`item_base_price`, the line's subtotal, tax, discount and final total separate. The snapshot retains the original selling-price basis so an inclusive amount is never divided by tax twice. The legacy desktop `sale_inline_item_price` remains the selling-basis amount. Adapters must use the correct representation for their request, rather than guessing from a field's numerical value. + +For an exclusive Paratha at 45 and quantity 2: subtotal 90, tax 4.50, total 94.50. A submitted 47.25 is rejected. For inclusive water at 30 and quantity 2: total 60, tax 2.86, subtotal 57.14. An existing inclusive line's displayed net unit amount is validated against its saved line; recalculation uses its stored inclusive selling-price snapshot. + +Round at the shop's currency precision. Sum stored line subtotals, not rounded net unit prices multiplied again: those can differ for inclusive prices and larger quantities. Split tax components must sum to the calculated tax. Submitted GST amounts cannot replace the tax engine's result. + +## Rule selection + +1. A persisted version-1 snapshot retains the agreed price and tax for an existing line. Catalogue edits do not reprice a dish already ordered. A submitted override is refused. Changing priced modifiers requires a separate line. +2. Catalogue flags determine variable prices: `open_price`, a daily price which has expired according to the shop's trading day, or an instant/quick item. Existing zero-priced catalogue products retain their established market-price meaning; they require a positive entered price, never a free default. Quick items may use their just-created persisted price when no separate price is supplied. +3. A fixed new product uses the catalogue price. A configured billing outlet takes precedence over a customer-category price list. Item overrides take precedence over that rule's percentage. Server-validated modifier deltas are added. A configured partner venue applies its markup using the existing venue rule. +4. A fixed submitted price must match the resulting selling price at the currency's precision. A higher price is as invalid as a lower price. A channel with no alternative rule uses the catalogue price; it cannot justify a different submitted price. + +Variable inputs must be finite, positive and no greater than the existing 1,000,000 limit. Booleans, empty strings, negative amounts and non-numeric values are refused. Product flags come from the catalogue; a client cannot turn a fixed item into an open-price item. + +The snapshot records version, item, source, channel, rule identity/revision, catalogue price/revision, modifier delta, venue adjustment, selling price, tax basis/rate and currency policy. It explains which rule admitted a price. It is not a customer-editable quote. + +## Failure and persistence + +Price mismatches return `item_price_mismatch` with item, expected price and submitted price. Invalid configuration and missing venue/outlet rules fail closed. The amendment endpoint returns a validation response, not a misleading “order not found” or transport error. The client keeps its draft and shows the server message; it must not automatically resubmit using another number. + +All lines are validated before a sale/stock write. A bad second line rejects the complete amendment, including a valid first line. Concurrency/payment guards still apply at save time. The successful response contains the calculated total, not the request's `total_amount`. A retry of an already committed desktop request returns that result before checking today's catalogue. + +Legacy orders without pricing snapshots are not automatically certified. A known fixed line must agree with the catalogue before receiving a snapshot. An inflated legacy line fails and requires review. A removed legacy catalogue item can be reduced at its saved amounts but cannot acquire new quantities without a verifiable price. No migration in this change alters completed bills. + +## Offline and configuration delivery + +The same resolver runs inside cloud and desktop; it does not require internet for a local sale. Local catalogue/configuration is authoritative for that device's admission. Offline cannot promise knowledge of a cloud edit made while disconnected. A stale client quote that differs from the local API's current rules is rejected. + +`items`, `branches` and `settings` already sync. The accompanying Gateway change adds branch-scoped `price_lists`, `billing_outlets` and `modifier_groups` to the batch lane. Outlet writes now stamp `updated_date`, which the sync watermark reads. Price-list/modifier deletions write the existing deletion tombstone before deleting. Venue settings already travel in `settings`. + +Missing named venue/outlet rules on desktop cause an error instead of falling back to house prices. Synced sales preserve the issuing API's snapshot. Sync is not a validation bypass for a client request, and it is not a mechanism for silently repairing old receipts. + +## Release and verification + +Release the Gateway/configuration support, verify master-data delivery, then deploy the shared API in cloud and desktop and the Captain correction. Old Captain versions sending a tax-inclusive amount as the base will be refused; update them before restaurant service resumes. Updating only the cloud API does not fix a desktop still running an old bundled API. + +Regression coverage includes the actual 45/47.25 failure, inclusive water, larger inclusive quantities, fixed-price over/under submissions, arbitrary channel labels, configured outlet/list/venue prices, variable and quick items, forged flags, immutable accepted prices, tax and modifier changes, partial-save prevention, unavailable local rules and idempotent retries. Retain the existing sale, payment, currency, tax-engine and concurrency suites. + +Before production rollout, exercise a new order and a later addition on a packaged desktop with Captain, print both exclusive and inclusive lines, and verify the persisted amounts and receipt totals. Audit affected historical orders separately. Any correction must follow the order's current payment state and the existing correction/audit workflow; changing catalogue prices is not a repair of a saved sale. From acc7b7b85e1a40ec91372fb6cf7c0650eee6ed12 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Thu, 1 Oct 2026 22:00:08 +0530 Subject: [PATCH 2/2] Preserve allocated order pricing and satisfy validation checks --- api/src/models/setting.model.js | 10 ++-- api/src/repositories/sale.repository.js | 54 ++++++++++++++++++- .../unit/repositories/sale.repository.test.js | 6 +-- languages/server/_english.json | 9 ++++ 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/api/src/models/setting.model.js b/api/src/models/setting.model.js index d13b1ae4a..a5641aef5 100644 --- a/api/src/models/setting.model.js +++ b/api/src/models/setting.model.js @@ -3668,7 +3668,7 @@ class SettingModel extends BaseModel { const items = await this.getCollection('items'); const used = await items.countDocuments({ license: this.normalizeId(this.licenseId), - modifier_group_ids: this.normalizeId(id), + modifier_group_ids: new ObjectId(String(id)), }); if (used > 0) { return { @@ -3679,12 +3679,12 @@ class SettingModel extends BaseModel { } const collection = await this.getCollection('modifier_groups'); const removed = await collection.findOne({ - _id: this.normalizeId(id), + _id: new ObjectId(String(id)), license: this.normalizeId(this.licenseId), }); if (removed) await BaseModel.deletedDocumentBackup('modifier_groups', removed); const r = await collection.deleteOne({ - _id: this.normalizeId(id), + _id: new ObjectId(String(id)), license: this.normalizeId(this.licenseId), }); if (!r.deletedCount) return { status: false, data: null, message: 'No such modifier group' }; @@ -3800,12 +3800,12 @@ class SettingModel extends BaseModel { if (!id) return { status: false, data: null, message: 'List id required' }; const collection = await this.getCollection('price_lists'); const removed = await collection.findOne({ - _id: this.normalizeId(id), + _id: new ObjectId(String(id)), license: this.normalizeId(this.licenseId), }); if (removed) await BaseModel.deletedDocumentBackup('price_lists', removed); const r = await collection.deleteOne({ - _id: this.normalizeId(id), + _id: new ObjectId(String(id)), license: this.normalizeId(this.licenseId), }); if (!r.deletedCount) return { status: false, data: null, message: 'No such price list' }; diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 5009fa3a1..d976c6089 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -10806,6 +10806,49 @@ class SalesRepository { const oldQty = oldItemsData[lineKey] ? parseFloat(oldItemsData[lineKey].quantity) : 0; if (oldItemsData[lineKey]) delete oldItemsData[lineKey]; + // A sealed transfer allocation is already a server-owned monetary + // record. Retained portions are reconciled against that record below, + // without certifying them against today's catalogue or tax rates. + if (previousLine && orderDoc.captain_transfer_allocation && qty <= oldQty) { + require('../utils/transfer-allocation').read(orderDoc, shop || {}); + pricingAuthority.assertPrice( + submittedPrice, + Number( + previousLine.unit_price ?? previousLine.item_base_price ?? previousLine.item_price + ), + (n) => Money.fromMinor(Money.toMinor(n, monetary), monetary), + previousLine.item_name + ); + if ( + item.modifiers !== undefined && + JSON.stringify(item.modifiers) !== JSON.stringify(previousLine.modifiers || []) + ) + throw new pricingAuthority.PricingError( + 'item_modifiers_changed', + 'Add a separate line to change priced modifiers.' + ); + updatedItems[existingIndex[lineKey]] = { + ...previousLine, + ...preparation, + quantity: qty, + item_quantity: qty, + item_description: newNote, + spice_level: spiceLevel.levelOf(item.spice_level ?? previousLine.spice_level), + }; + if (qty < oldQty) + changesItems.push({ + item_id: productId, + ...preparation, + item_name: previousLine.item_name, + item_quantity: oldQty - qty, + process: 'cancel', + item_description: newNote, + ...require('../utils/kitchen-amount').snapshot(previousLine), + }); + incomingProductIds.push(lineKey); + continue; + } + let itemDoc = null; if (mongoose.Types.ObjectId.isValid(productId)) { itemDoc = await itemCollection.findOne({ @@ -10822,7 +10865,9 @@ class SalesRepository { ); pricingAuthority.assertPrice( submittedPrice, - Number(previousLine.unit_price ?? previousLine.item_price), + Number( + previousLine.unit_price ?? previousLine.item_base_price ?? previousLine.item_price + ), (n) => Money.fromMinor(Money.toMinor(n, monetary), monetary), previousLine.item_name ); @@ -10858,7 +10903,8 @@ class SalesRepository { let amounts; try { if (previousLine) { - const storedUnit = previousLine.unit_price ?? previousLine.item_price; + const storedUnit = + previousLine.unit_price ?? previousLine.item_base_price ?? previousLine.item_price; pricingAuthority.assertPrice( submittedPrice, Number(storedUnit), @@ -10976,6 +11022,10 @@ class SalesRepository { negative_stock: itemDoc.negative_stock || false, company_price_total: Number(itemDoc.company_price || 0) * qty, }; + // The reconciled allocation owns a mixture of historical and newly + // added portions; a new-item quote must not certify all old portions. + if (previousLine && orderDoc.captain_transfer_allocation && !previousLine.pricing) + delete nextLine.pricing; if (previousLine) updatedItems[existingIndex[lineKey]] = nextLine; else updatedItems.push({ ...nextLine, item_status: 'Add', return: false }); } diff --git a/api/tests/unit/repositories/sale.repository.test.js b/api/tests/unit/repositories/sale.repository.test.js index 2fa19e057..b9a6aa639 100644 --- a/api/tests/unit/repositories/sale.repository.test.js +++ b/api/tests/unit/repositories/sale.repository.test.js @@ -1427,7 +1427,7 @@ describe('SalesRepository', () => { * charged the normal price is a bad QR code, a customer charged a markup * nobody agreed is a complaint. */ - test('a venue nobody recognises falls back to house prices', async () => { + test('a venue nobody recognises rejects the order instead of guessing a price', async () => { openShopSelling(280); settingsHolding({ partner_venues: [ROYAL] }); @@ -1438,8 +1438,8 @@ describe('SalesRepository', () => { items: [{ item_id: FAKE_ITEM, item_quantity: 1 }], }); - expect(r.data.total).toBe(280); - expect(placed().venue).toBeNull(); + expect(r).toMatchObject({ status: false, data: { state: 'pricing_rule_unavailable' } }); + expect(collections.sales.insertOne).not.toHaveBeenCalled(); }); /* diff --git a/languages/server/_english.json b/languages/server/_english.json index de5c4d1de..2c0d976c7 100644 --- a/languages/server/_english.json +++ b/languages/server/_english.json @@ -64,6 +64,9 @@ "Active categories retrieved successfully": [ "src/services/category.service.js" ], + "Add a separate line to change priced modifiers.": [ + "src/services/sale.service.js" + ], "Add at least one item to adjust": [ "src/repositories/item.repository.js" ], @@ -1594,6 +1597,9 @@ "Price list saved": [ "src/models/setting.model.js" ], + "Pricing settings could not be loaded. Try again before placing the order.": [ + "src/repositories/sale.repository.js" + ], "Print setting retrieval failed": [ "src/repositories/user.repository.js" ], @@ -2542,6 +2548,9 @@ "Variant value \"": [ "src/services/item.service.js" ], + "Venue pricing is unavailable. Refresh the menu or contact the restaurant.": [ + "src/repositories/sale.repository.js" + ], "Voice ordering is not set up for this shop": [ "src/services/transcribe.service.js" ],