diff --git a/frontend/src/app/(dashboard)/pos/page.tsx b/frontend/src/app/(dashboard)/pos/page.tsx index ac341e61..b801945c 100644 --- a/frontend/src/app/(dashboard)/pos/page.tsx +++ b/frontend/src/app/(dashboard)/pos/page.tsx @@ -33,6 +33,7 @@ import { useFormatCurrency } from '@/hooks/useFormatCurrency'; import { useSupportTicketStatus } from '@/hooks/useSupportTicketStatus'; import { useSupportDiagnosticsPreview } from '@/hooks/useSupportDiagnosticsPreview'; import { getCurrencySymbol, getCountryByCode } from '@/lib/countries'; +import { resolveScannedProduct } from '@/lib/scale-barcode'; import { buildAppendItemsFingerprint, clearAppendAttempt, @@ -49,10 +50,6 @@ import { const PREPAID_ATTEMPT_STORAGE_KEY = 'flo.prepaid.checkout.attempt'; const POSTPAID_ATTEMPT_STORAGE_KEY = 'flo.postpaid.order.attempt'; -function normalizeBarcode(value: string | null | undefined) { - return value?.trim() || ''; -} - interface PostpaidAttempt { userId: string; fingerprint: string; @@ -415,10 +412,10 @@ export default function POSPage() { || !!paymentBill || showCustomerPrompt || showPrepaidCheckout; useBarcodeScanner((code) => { - const normalizedCode = normalizeBarcode(code); - const product = products.find((p) => normalizeBarcode(p.barcode) === normalizedCode); - if (product) { - handleProductClick(product); + const scan = resolveScannedProduct(code, products); + if (scan) { + if (scan.scaleBarcode) cart.addItem(scan.product, scan.quantity); + else handleProductClick(scan.product); } else { toast.error(t('barcodeNotFound', { code })); } diff --git a/frontend/src/app/(dashboard)/products/page.tsx b/frontend/src/app/(dashboard)/products/page.tsx index 147b16f5..5832c979 100644 --- a/frontend/src/app/(dashboard)/products/page.tsx +++ b/frontend/src/app/(dashboard)/products/page.tsx @@ -94,6 +94,7 @@ export default function ProductsPage() { const [addonList, setAddonList] = useState<{ id?: number | string; name: string; price: number; is_active?: boolean }[]>([]); const [form, setForm] = useState({ name: '', category_id: '', price: '', cost_price: '', cb_percent: '', sku: '', barcode: '', + sale_unit: 'each' as Product['sale_unit'], allow_fractional_quantity: false, weight_precision: '3', tax_category_id: '', tax_behavior: 'country_default', description: '', track_inventory: false, stock_quantity: '0', low_stock_threshold: '5', is_active: true, tags: [] as string[], @@ -238,6 +239,7 @@ export default function ProductsPage() { const resetForm = () => { setForm({ name: '', category_id: '', price: '', cost_price: '', cb_percent: '', sku: '', barcode: '', + sale_unit: 'each', allow_fractional_quantity: false, weight_precision: '3', tax_category_id: '', tax_behavior: 'country_default', description: '', track_inventory: false, stock_quantity: '0', low_stock_threshold: '5', is_active: true, tags: [], customTag: '', addon_group_ids: [], image_url: null, @@ -263,6 +265,9 @@ export default function ProductsPage() { cb_percent: product.cb_percent === null || product.cb_percent === undefined ? '' : String(product.cb_percent), sku: product.sku || '', barcode: product.barcode || '', + sale_unit: product.sale_unit || 'each', + allow_fractional_quantity: !!product.allow_fractional_quantity, + weight_precision: String(product.weight_precision ?? 3), tax_category_id: product.tax_category_id || '', tax_behavior: product.tax_behavior || 'country_default', description: product.description || '', @@ -298,6 +303,9 @@ export default function ProductsPage() { cb_percent: cbPercentVal, sku: form.sku || null, barcode: form.barcode || null, + sale_unit: form.sale_unit, + allow_fractional_quantity: form.allow_fractional_quantity, + weight_precision: Number(form.weight_precision), tax_category_id: form.tax_category_id || null, tax_behavior: form.tax_category_id ? form.tax_behavior : 'country_default', description: form.description || null, @@ -703,6 +711,48 @@ export default function ProductsPage() { className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand outline-none font-mono" />

{t('fieldBarcodeHint')}

+
+
+ + +
+
+ + setForm({ ...form, weight_precision: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand outline-none" + /> +
+ +
diff --git a/frontend/src/components/pos/ProductGrid.tsx b/frontend/src/components/pos/ProductGrid.tsx index a55f49b3..a2771b74 100644 --- a/frontend/src/components/pos/ProductGrid.tsx +++ b/frontend/src/components/pos/ProductGrid.tsx @@ -11,6 +11,7 @@ import api from '@/lib/api'; import { useTranslations } from 'use-intl'; import { parseDbTimestamp } from '@/lib/utils'; import { useFormatCurrency } from '@/hooks/useFormatCurrency'; +import { resolveScannedProduct } from '@/lib/scale-barcode'; const CATEGORY_COLORS: Record = { red: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', activeBg: 'bg-red-500', activeText: 'text-white' }, @@ -37,10 +38,6 @@ function getCategoryColorClasses(color: string | null | undefined) { return CATEGORY_COLORS[color.toLowerCase()] || null; } -function normalizeBarcode(value: string | null | undefined) { - return value?.trim() || ''; -} - interface Props { categories: Category[]; products: Product[]; @@ -90,9 +87,10 @@ export default function ProductGrid({ // action into this field works regardless of typing speed. const trimmed = search.trim(); if (!trimmed) return; - const match = products.find((p) => normalizeBarcode(p.barcode) === trimmed); + const match = resolveScannedProduct(trimmed, products); if (match) { - onProductClick(match); + if (match.scaleBarcode) cart.addItem(match.product, match.quantity); + else onProductClick(match.product); setSearch(''); } }} diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 0751fd62..d6b0796a 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -824,16 +824,19 @@ "fieldBarcode": "Barcode", "fieldBarcodeHint": "Scan this product at POS to add it to the cart directly.", "fieldBarcodePlaceholder": "Scan or type a barcode", + "fieldAllowFractionalQuantity": "Fractional quantity", "fieldCategory": "Category", "fieldCostPrice": "Cost Price", "fieldImage": "Product Image", "fieldLowStockThreshold": "Low Stock Threshold", "fieldName": "Name", + "fieldSaleUnit": "Sale unit", "fieldSku": "SKU", "fieldStock": "Current Stock", "fieldTags": "Tags", "fieldTaxType": "Tax Type", "fieldTrackInventory": "Track Inventory", + "fieldWeightPrecision": "Precision", "hiddenOnPos": "(Hidden on POS)", "imageCamera": "Camera", "imageCompressFailed": "Could not compress this image enough. Try a tighter crop or another image.", @@ -865,6 +868,10 @@ "optional": "Optional", "optionalTag": "Optional", "priceLabel": "Price ({currency})", + "saleUnitEach": "Each", + "saleUnitG": "g", + "saleUnitKg": "kg", + "saleUnitLb": "lb", "reassignAndDelete": "Products reassigned and category deleted", "required": "Required", "requiredSelection": "Required selection", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 668be929..637c5766 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -824,16 +824,19 @@ "fieldBarcode": "Código de barras", "fieldBarcodeHint": "Escaneá este producto en el POS para agregarlo directamente al carrito.", "fieldBarcodePlaceholder": "Escaneá o escribí un código de barras", + "fieldAllowFractionalQuantity": "Cantidad fraccionaria", "fieldCategory": "Categoría", "fieldCostPrice": "Precio de costo", "fieldImage": "Imagen del producto", "fieldLowStockThreshold": "Umbral de Existencias Bajas", "fieldName": "Nombre", + "fieldSaleUnit": "Unidad de venta", "fieldSku": "SKU", "fieldStock": "Stock Actual", "fieldTags": "Etiquetas y Atributos", "fieldTaxType": "Tipo de impuesto", "fieldTrackInventory": "Llevar control de inventario", + "fieldWeightPrecision": "Precisión", "hiddenOnPos": "(Oculto en POS)", "imageCamera": "Cámara", "imageCompressFailed": "No se pudo comprimir lo suficiente esta imagen. Probá un recorte más ajustado u otra imagen.", @@ -865,6 +868,10 @@ "optional": "Opcional", "optionalTag": "Opcional", "priceLabel": "Precio ({currency})", + "saleUnitEach": "Unidad", + "saleUnitG": "g", + "saleUnitKg": "kg", + "saleUnitLb": "lb", "reassignAndDelete": "Productos reasignados y categoría eliminada", "required": "Obligatorio", "requiredSelection": "Selección obligatoria", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 14c2399c..5d29d3d3 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -824,16 +824,19 @@ "fieldBarcode": "بارکد", "fieldBarcodeHint": "این کالا را در صندوق پویش کنید تا بی‌درنگ به سبد افزوده شود.", "fieldBarcodePlaceholder": "بارکد را پویش کنید یا بنویسید", + "fieldAllowFractionalQuantity": "مقدار کسری", "fieldCategory": "دسته", "fieldCostPrice": "بهای خرید", "fieldImage": "نگاره کالا", "fieldLowStockThreshold": "آستانه کمبود موجودی", "fieldName": "نام", + "fieldSaleUnit": "واحد فروش", "fieldSku": "شناسه کالا", "fieldStock": "موجودی کنونی", "fieldTags": "برچسب‌ها", "fieldTaxType": "گونه مالیات", "fieldTrackInventory": "پیگیری موجودی", + "fieldWeightPrecision": "دقت", "hiddenOnPos": "(پنهان در صندوق)", "imageCamera": "دوربین", "imageCompressFailed": "این تصویر به اندازه کافی فشرده نشد. برش فشرده‌تر یا تصویر دیگری را بیازمایید.", @@ -865,6 +868,10 @@ "optional": "اختیاری", "optionalTag": "اختیاری", "priceLabel": " ({currency}) قیمت ", + "saleUnitEach": "عدد", + "saleUnitG": "گرم", + "saleUnitKg": "کیلوگرم", + "saleUnitLb": "پوند", "reassignAndDelete": "کالاها دوباره دسته‌بندی شدند و دسته پاک شد", "required": "بایسته", "requiredSelection": "گزینش بایسته", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index b3e82ba6..3b9e9ccc 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -824,16 +824,19 @@ "fieldBarcode": "Código de barras", "fieldBarcodeHint": "Escanear este produto no PDV para adicioná-lo diretamente ao carrinho.", "fieldBarcodePlaceholder": "Escanear ou digitar um código de barras", + "fieldAllowFractionalQuantity": "Quantidade fracionária", "fieldCategory": "Categoria", "fieldCostPrice": "Preço de Custo", "fieldImage": "Imagem do Produto", "fieldLowStockThreshold": "Limite de Estoque Baixo", "fieldName": "Nome", + "fieldSaleUnit": "Unidade de venda", "fieldSku": "SKU", "fieldStock": "Estoque Atual", "fieldTags": "Etiquetas", "fieldTaxType": "Tipo de Imposto", "fieldTrackInventory": "Controlar Estoque", + "fieldWeightPrecision": "Precisão", "hiddenOnPos": "(Oculto no POS)", "imageCamera": "Câmera", "imageCompressFailed": "Não foi possível comprimir esta imagem o suficiente. Tente um recorte mais justo ou outra imagem.", @@ -865,6 +868,10 @@ "optional": "Opcional", "optionalTag": "Opcional", "priceLabel": "Preço ({currency})", + "saleUnitEach": "Unidade", + "saleUnitG": "g", + "saleUnitKg": "kg", + "saleUnitLb": "lb", "reassignAndDelete": "Produtos reatribuídos e categoria excluída", "required": "Obrigatório", "requiredSelection": "Seleção obrigatória", diff --git a/frontend/src/lib/scale-barcode.ts b/frontend/src/lib/scale-barcode.ts new file mode 100644 index 00000000..1ecaba21 --- /dev/null +++ b/frontend/src/lib/scale-barcode.ts @@ -0,0 +1,74 @@ +import type { Product } from '@/lib/types'; + +export type ScaleBarcodeConfig = { + prefix: string; + productDigits: number; + weightDigits: number; + unit: 'grams'; +}; + +export type ParsedScaleBarcode = { + plu: string; + quantity: number; +}; + +export const DEFAULT_SCALE_BARCODE_CONFIG: ScaleBarcodeConfig = { + prefix: '21', + productDigits: 5, + weightDigits: 5, + unit: 'grams', +}; + +export function normalizeBarcode(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function parseScaleBarcode( + code: string, + config: ScaleBarcodeConfig = DEFAULT_SCALE_BARCODE_CONFIG, +): ParsedScaleBarcode | null { + const normalized = normalizeBarcode(code); + const payloadLength = config.prefix.length + config.productDigits + config.weightDigits; + if (normalized.length < payloadLength || !normalized.startsWith(config.prefix) || !/^\d+$/.test(normalized)) { + return null; + } + + const pluStart = config.prefix.length; + const plu = normalized.slice(pluStart, pluStart + config.productDigits); + const weightRaw = normalized.slice(pluStart + config.productDigits, payloadLength); + const grams = Number.parseInt(weightRaw, 10); + if (!Number.isSafeInteger(grams) || grams <= 0) return null; + return { plu, quantity: grams / 1000 }; +} + +function roundedQuantity(value: number, precision: number | null | undefined): number { + const digits = Number.isSafeInteger(precision) ? Math.min(Math.max(Number(precision), 0), 4) : 3; + return Number(value.toFixed(digits)); +} + +function quantityForProductUnit(parsed: ParsedScaleBarcode, product: Product): number | null { + if (product.sale_unit === 'kg') return roundedQuantity(parsed.quantity, product.weight_precision); + if (product.sale_unit === 'g') return roundedQuantity(parsed.quantity * 1000, product.weight_precision); + if (product.sale_unit === 'lb') return roundedQuantity(parsed.quantity / 0.45359237, product.weight_precision); + return null; +} + +export function resolveScannedProduct( + code: string, + products: Product[], +): { product: Product; quantity: number; scaleBarcode: ParsedScaleBarcode | null } | null { + const normalizedCode = normalizeBarcode(code); + const exact = products.find((product) => normalizeBarcode(product.barcode) === normalizedCode); + if (exact) return { product: exact, quantity: 1, scaleBarcode: null }; + + const parsed = parseScaleBarcode(normalizedCode); + if (!parsed) return null; + const product = products.find((candidate) => { + if (!candidate.allow_fractional_quantity) return false; + if (candidate.sale_unit !== 'kg' && candidate.sale_unit !== 'g' && candidate.sale_unit !== 'lb') return false; + return normalizeBarcode(candidate.barcode) === parsed.plu || normalizeBarcode(candidate.sku) === parsed.plu; + }); + if (!product) return null; + const quantity = quantityForProductUnit(parsed, product); + return quantity ? { product, quantity, scaleBarcode: parsed } : null; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 3ed5df42..3c4666dc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -62,6 +62,9 @@ export interface Product { name: string; sku: string | null; barcode: string | null; + sale_unit: 'each' | 'kg' | 'g' | 'lb'; + allow_fractional_quantity: boolean; + weight_precision: number; description: string | null; price: number; cost_price: number | null; diff --git a/main/db.ts b/main/db.ts index 229913af..4a452118 100644 --- a/main/db.ts +++ b/main/db.ts @@ -4086,6 +4086,67 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ } }, }, + { + version: 76, + name: 'add_refunds_and_refund_idempotency', + up: () => { + // Bill-level, amount-based refunds (#278), optionally linked to a + // single order_item for the "already prepared, must be pulled off a + // paid bill" case. Deliberately not linked to order_items for the + // common case — a refund is "amount_cents refunded via original_method + // against bill_id", mirroring how payments are recorded as free-form + // lines in bills.payment_details rather than tied to line items. + // shift_id is nullable with no FK: no `shifts` table exists yet + // (day-close/shift reconciliation is deferred to #279). + db.exec(` + CREATE TABLE IF NOT EXISTS refunds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bill_id INTEGER NOT NULL REFERENCES bills(id), + order_item_id INTEGER REFERENCES order_items(id), + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + method TEXT NOT NULL, + reason TEXT, + shift_id TEXT, + approved_by TEXT NOT NULL REFERENCES users(id), + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_refunds_bill ON refunds(bill_id); + CREATE INDEX IF NOT EXISTS idx_refunds_order_item ON refunds(order_item_id); + + -- Mirrors payment_idempotency's FINAL (post v53/v54) user-scoped + -- shape directly: this table is brand new, so it never has + -- pre-user-scoped rows and needs no 'legacy' compat owner. + CREATE TABLE IF NOT EXISTS refund_idempotency ( + user_id TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + bill_id TEXT NOT NULL, + request_hash TEXT NOT NULL, + response_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, idempotency_key) + ); + CREATE INDEX IF NOT EXISTS idx_refund_idempotency_bill ON refund_idempotency(bill_id); + `); + }, + }, + { + version: 77, + name: 'add_weighted_product_metadata', + up: () => { + const columns = db.prepare(`PRAGMA table_info(products)`).all() as Array<{ name: string }>; + const hasColumn = (name: string) => columns.some((column) => column.name === name); + if (!hasColumn('sale_unit')) { + db.exec(`ALTER TABLE products ADD COLUMN sale_unit TEXT NOT NULL DEFAULT 'each' CHECK (sale_unit IN ('each', 'kg', 'g', 'lb'))`); + } + if (!hasColumn('allow_fractional_quantity')) { + db.exec(`ALTER TABLE products ADD COLUMN allow_fractional_quantity INTEGER NOT NULL DEFAULT 0`); + } + if (!hasColumn('weight_precision')) { + db.exec(`ALTER TABLE products ADD COLUMN weight_precision INTEGER NOT NULL DEFAULT 3 CHECK (weight_precision BETWEEN 0 AND 4)`); + } + }, + }, ]; function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void { @@ -4242,6 +4303,9 @@ function createSchema(): void { cost REAL DEFAULT 0, sku TEXT, barcode TEXT, + sale_unit TEXT NOT NULL DEFAULT 'each' CHECK (sale_unit IN ('each', 'kg', 'g', 'lb')), + allow_fractional_quantity INTEGER NOT NULL DEFAULT 0, + weight_precision INTEGER NOT NULL DEFAULT 3 CHECK (weight_precision BETWEEN 0 AND 4), image_url TEXT, is_active INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0, diff --git a/main/ipc.ts b/main/ipc.ts index 1f2b9bd7..5b907719 100644 --- a/main/ipc.ts +++ b/main/ipc.ts @@ -495,9 +495,11 @@ export function registerIpcHandlers( const today = new Date().toISOString().slice(0, 10); const bills = db.prepare(` - SELECT COUNT(*) as bill_count, COALESCE(SUM(total), 0) as revenue - FROM bills WHERE date(created_at) = date(?) AND payment_status = 'paid' - `).get(today) as { bill_count: number; revenue: number }; + SELECT + (SELECT COUNT(*) FROM bills WHERE date(paid_at) = date(?)) as bill_count, + COALESCE((SELECT SUM(paid_amount) FROM bills WHERE date(paid_at) = date(?)), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE date(created_at) = date(?)), 0) as revenue + `).get(today, today, today) as { bill_count: number; revenue: number }; const covers = db.prepare(` SELECT COALESCE(SUM(guest_count), 0) as covers FROM orders diff --git a/main/kds-server.ts b/main/kds-server.ts index ccebe630..20e5518a 100644 --- a/main/kds-server.ts +++ b/main/kds-server.ts @@ -303,7 +303,7 @@ export function startKdsServer(): Promise { AND active_oi.status NOT IN ('served', 'cancelled') WHERE active_o.status NOT IN ('pending', 'preparing', 'ready', 'served', 'cancelled') ) - AND oi.status NOT IN ('completed', 'cancelled', 'void_adjustment') + AND oi.status NOT IN ('completed', 'cancelled', 'void_adjustment', 'refunded') AND (oi.status != 'voided' OR oi.voided_at IS NULL OR oi.voided_at > ?) `; const orderParams: string[] = [voidedCutoff]; @@ -348,7 +348,7 @@ export function startKdsServer(): Promise { // #150: hide the void reversal line (bill adjustment, not a kitchen // item) and age voided items off the board after their grace period. const isVisibleItem = (item: any, order: any) => item.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(item.status) + && !['completed', 'cancelled', 'refunded'].includes(item.status) && (item.status !== 'voided' || isVoidedItemKdsVisible(item.voided_at)) && isKdsStationItemAllowed(stationIds, stationRoutingCategoryIds, order.kitchen_station_id, item.category_id, order.kitchen_station_id ? stationScope?.categoryIdsByStation[String(order.kitchen_station_id)] : undefined, stationScope.hasUnrestrictedStation); @@ -435,7 +435,7 @@ export function startKdsServer(): Promise { // #150: locked once voided — see main/routes/order-items.ts for the same rule. if (item.status === 'voided') return { statusCode: 400, error: 'This item has been voided and can no longer be updated' }; if (item.status === 'void_adjustment') return { statusCode: 400, error: 'This bill adjustment cannot be updated from KDS' }; - if (item.status === 'completed' || item.status === 'cancelled') { + if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'refunded') { return { statusCode: 400, error: 'This terminal item cannot be updated from KDS' }; } @@ -460,7 +460,7 @@ export function startKdsServer(): Promise { ? db.prepare(` UPDATE order_items SET status = ?, updated_at = datetime('now') - WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled') + WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled', 'refunded') `).run(status, req.params.id) : db.prepare(` UPDATE order_items diff --git a/main/routes/bills.ts b/main/routes/bills.ts index 5ccf96d0..3c9bfeaa 100644 --- a/main/routes/bills.ts +++ b/main/routes/bills.ts @@ -158,7 +158,7 @@ function getPersistedChildTaxBreakdowns( for (const breakdown of parsed) { while (itemIndex < sourceItems.length) { const item = sourceItems[itemIndex++]; - if (['cancelled', 'voided', 'void_adjustment'].includes(item.status)) continue; + if (['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(item.status)) continue; const itemBreakdown = parseTaxSnapshot(item.tax_breakdown); if (!Array.isArray(itemBreakdown) || itemBreakdown.length === 0) continue; result.set(Number(item.id), breakdown); @@ -1049,7 +1049,7 @@ function collectLegacyTaxContribution( ? new Map() : getPersistedChildTaxBreakdowns(sourceBreakdownRaw, items); for (const item of items) { - if (['cancelled', 'voided', 'void_adjustment'].includes(item.status) || hasSnapshotLines(item.tax_snapshot)) continue; + if (['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(item.status) || hasSnapshotLines(item.tax_snapshot)) continue; const sourceCents = persistedBreakdowns.has(Number(item.id)) ? taxBreakdownMinorTotal(persistedBreakdowns.get(Number(item.id))) : Number(item.tax_amount || 0) * taxRatio * 100; @@ -1176,7 +1176,7 @@ function getSplitBillAllocationWeights( const weights = bills.map((bill) => { const byItem = quantities.get(Number(bill.id)) || new Map(); return items - .filter((item) => !['cancelled', 'voided', 'void_adjustment'].includes(item.status)) + .filter((item) => !['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(item.status)) .reduce((sum, item) => { const quantity = byItem.get(Number(item.id)) || 0; if (quantity <= 0 || Number(item.quantity) <= 0) return sum; @@ -1185,7 +1185,7 @@ function getSplitBillAllocationWeights( }); const snapshotItems = items - .filter((item) => !['cancelled', 'voided', 'void_adjustment'].includes(item.status) && hasSnapshotLines(item.tax_snapshot)) + .filter((item) => !['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(item.status) && hasSnapshotLines(item.tax_snapshot)) const snapshotWeights = snapshotItems.map((item) => { if (['voided', 'void_adjustment'].includes(item.status)) return null; const itemWeights = bills.map((bill) => ( @@ -1267,7 +1267,7 @@ function getTaxBreakdownWeights( if (!Array.isArray(parsed[0])) { const ownerWeightsByKey = new Map(); for (const item of items) { - if (['cancelled', 'voided', 'void_adjustment'].includes(item.status) || hasSnapshotLines(item.tax_snapshot)) continue; + if (['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(item.status) || hasSnapshotLines(item.tax_snapshot)) continue; const itemBreakdown = parseTaxSnapshot(item.tax_breakdown); if (!Array.isArray(itemBreakdown)) continue; const itemComponents = itemBreakdown.flatMap((entry: any) => Array.isArray(entry) ? entry : [entry]); @@ -1289,7 +1289,7 @@ function getTaxBreakdownWeights( return parsed.map(() => { while (itemIndex < items.length) { const item = items[itemIndex++]; - if (['cancelled', 'voided', 'void_adjustment'].includes(item.status)) continue; + if (['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(item.status)) continue; const breakdown = parseTaxSnapshot(item.tax_breakdown); if (Array.isArray(breakdown) && breakdown.length > 0) { return itemWeights(item); @@ -1430,7 +1430,7 @@ router.post('/:id/split-check', requireRole(...ROLE_ACCESS.ownerManagerCashier), if (txnSource.split_group_id || Number((db.prepare('SELECT COUNT(*) AS n FROM bills WHERE order_id = ?').get(txnSource.order_id) as any).n) > 1) { throw Object.assign(new Error('This check has already been split'), { statusCode: 409 }); } - const txnActiveItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment') ORDER BY id").all(txnSource.order_id) as any[]; + const txnActiveItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded') ORDER BY id").all(txnSource.order_id) as any[]; const txnItemById = new Map(txnActiveItems.map((item) => [Number(item.id), item])); const txnAssigned = new Map(); diff --git a/main/routes/held-orders.ts b/main/routes/held-orders.ts index b409fe65..ab8faad9 100644 --- a/main/routes/held-orders.ts +++ b/main/routes/held-orders.ts @@ -4,7 +4,7 @@ import { getDatabase, now, withTxn } from '../db'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS } from '../../shared/role-permissions'; import { randomUUID } from 'crypto'; -import { validateItemNotes, validateOrderNotes } from './orders-validation'; +import { validateItemNotes, validateOrderNotes, validateProductQuantity } from './orders-validation'; const router = Router(); const heldOrderReadRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); @@ -43,8 +43,15 @@ function validateHeldOrderItem(item: unknown, db: any): void { if (!isRecord(item.product) || !isValidIdentifier(item.product.id)) { throw new Error('Each held-order item must have a valid product'); } - if (!Number.isSafeInteger(item.quantity) || item.quantity <= 0) { - throw new Error('Each held-order item must have a positive integer quantity'); + if (typeof item.quantity !== 'number' || !Number.isFinite(item.quantity) || item.quantity <= 0) { + throw new Error('Each held-order item must have a positive quantity'); + } + if (!Number.isInteger(item.quantity)) { + const product = db.prepare( + 'SELECT name, allow_fractional_quantity, weight_precision FROM products WHERE id = ? AND deleted_at IS NULL' + ).get(item.product.id) as any; + if (!product) throw new Error('Fractional held-order items must reference a catalog product'); + validateProductQuantity(product, item.quantity); } if (!Array.isArray(item.addons) || item.addons.some((addon: unknown) => !isRecord(addon) || !isValidIdentifier(addon.id))) { throw new Error('Held-order item addons must be an array of valid addons'); diff --git a/main/routes/index.ts b/main/routes/index.ts index 8f91cfdb..aef8e161 100644 --- a/main/routes/index.ts +++ b/main/routes/index.ts @@ -8,6 +8,7 @@ import { addonGroupRoutes } from './addon-groups'; import { orderRoutes } from './orders'; import { orderItemRoutes } from './order-items'; import { billRoutes, syncUnpaidBillsForOrder } from './bills'; +import { refundRoutes } from './refunds'; import { tableRoutes } from './tables'; import { kitchenStationRoutes } from './kitchen-stations'; import { kitchenRoutes } from './kitchen'; @@ -84,6 +85,7 @@ export function registerRoutes(app: Express): void { app.use('/api/order-items', orderItemRoutes); app.use('/api/kitchen', kitchenRoutes); app.use('/api/bills', billRoutes); + app.use('/api/refunds', refundRoutes); app.use('/api/tables', tableRoutes); app.use('/api/kitchen-stations', kitchenStationRoutes); app.use('/api/customers', customerRoutes); @@ -302,7 +304,7 @@ export function registerRoutes(app: Express): void { // A repeated request against an already terminal item is an // intentional idempotent no-op. Check it before the parent terminal // policy so a retry cannot turn a harmless repeat into a new error. - if (['cancelled', 'voided', 'void_adjustment'].includes(currentItem.status)) { + if (['cancelled', 'voided', 'void_adjustment', 'refunded'].includes(currentItem.status)) { if (!hasRole(userRole, ROLE_ACCESS.ownerManager)) { throw Object.assign(new Error('Only owner or manager can cancel this item'), { statusCode: 403 }); } @@ -421,7 +423,7 @@ export function registerRoutes(app: Express): void { } // Recalculate order totals excluding cancelled, voided, and void_adjustment items - const activeItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment')") + const activeItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded')") .all(orderId) as any[]; let subtotal = 0; let totalTax = 0; @@ -607,7 +609,7 @@ export function registerRoutes(app: Express): void { .run(now(), itemId); // Recalculate order totals - const activeItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment')") + const activeItems = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded')") .all(orderId) as any[]; let subtotal = 0; let totalTax = 0; diff --git a/main/routes/kds.ts b/main/routes/kds.ts index 68bba095..678bfecb 100644 --- a/main/routes/kds.ts +++ b/main/routes/kds.ts @@ -150,7 +150,7 @@ router.get('/orders', requireKdsEnabled, (req: Request, res: Response) => { const allVisibleItems = (orders as any[]) .flatMap((o) => itemsByOrder[o.id] || []) .filter((i) => i.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(i.status) + && !['completed', 'cancelled', 'refunded'].includes(i.status) && (i.status !== 'voided' || isVoidedItemKdsVisible(i.voided_at)) && (!allowedProductIds || allowedProductIds.has(String(i.product_id))) && isKdsStationItemAllowed(payloadStationIds, requestedRoutingCategoryIds, ordersById.get(i.order_id)?.kitchen_station_id, i.category_id, ordersById.get(i.order_id)?.kitchen_station_id ? requestedScope.categoryIdsByStation[String(ordersById.get(i.order_id)?.kitchen_station_id)] : undefined, requestedScope.hasUnrestrictedStation)); @@ -162,7 +162,7 @@ router.get('/orders', requireKdsEnabled, (req: Request, res: Response) => { // item) and age voided items off the board after their grace period. const visibleItems = (itemsByOrder[order.id] || []) .filter((i) => i.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(i.status) + && !['completed', 'cancelled', 'refunded'].includes(i.status) && (i.status !== 'voided' || isVoidedItemKdsVisible(i.voided_at)) && (!allowedProductIds || allowedProductIds.has(String(i.product_id))) && isKdsStationItemAllowed(payloadStationIds, requestedRoutingCategoryIds, order.kitchen_station_id, i.category_id, order.kitchen_station_id ? requestedScope.categoryIdsByStation[String(order.kitchen_station_id)] : undefined, requestedScope.hasUnrestrictedStation)) @@ -311,7 +311,7 @@ router.get('/display', requireKdsEnabled, (req: Request, res: Response) => { FROM order_items oi JOIN orders o ON oi.order_id = o.id LEFT JOIN tables t ON o.table_id = t.id - WHERE oi.status NOT IN ('completed', 'cancelled', 'served', 'void_adjustment') + WHERE oi.status NOT IN ('completed', 'cancelled', 'served', 'void_adjustment', 'refunded') AND (oi.status != 'voided' OR oi.voided_at IS NULL OR oi.voided_at > ?) AND o.status != 'cancelled' `; @@ -422,7 +422,7 @@ router.patch('/items/:id/status', requireKdsEnabled, (req: Request, res: Respons if (item.status === 'void_adjustment') { throw new Error('IMMUTABLE_KDS_ITEM'); } - if (item.status === 'completed' || item.status === 'cancelled') { + if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'refunded') { throw new Error('TERMINAL_KDS_ITEM'); } @@ -447,7 +447,7 @@ router.patch('/items/:id/status', requireKdsEnabled, (req: Request, res: Respons } const updateResult = expectedStatus === undefined - ? db.prepare("UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled')").run(status, now(), req.params.id) + ? db.prepare("UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled', 'refunded')").run(status, now(), req.params.id) : db.prepare('UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status = ?').run(status, now(), req.params.id, expectedStatus); if (updateResult.changes !== 1) throw new Error('STATUS_CONFLICT'); diff --git a/main/routes/kitchen.ts b/main/routes/kitchen.ts index 882eb444..1bea272e 100644 --- a/main/routes/kitchen.ts +++ b/main/routes/kitchen.ts @@ -98,7 +98,7 @@ router.get('/orders', (req: Request, res: Response) => { // Resolve addons for every visible item in one batched call. const allVisibleItems = rawItems.filter( (i) => i.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(i.status) + && !['completed', 'cancelled', 'refunded'].includes(i.status) && (i.status !== 'voided' || isVoidedItemKdsVisible(i.voided_at)) && (!allowedProductIds || allowedProductIds.has(String(i.product_id))) && isKdsStationItemAllowed(stationIds, stationRoutingCategoryIds, ordersById.get(i.order_id)?.kitchen_station_id, i.category_id, ordersById.get(i.order_id)?.kitchen_station_id ? stationScope.categoryIdsByStation[String(ordersById.get(i.order_id)?.kitchen_station_id)] : undefined, stationScope.hasUnrestrictedStation) @@ -110,7 +110,7 @@ router.get('/orders', (req: Request, res: Response) => { const orderRawItems = itemsByOrder[order.id] || []; const visibleItems = orderRawItems .filter((i) => i.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(i.status) + && !['completed', 'cancelled', 'refunded'].includes(i.status) && (i.status !== 'voided' || isVoidedItemKdsVisible(i.voided_at)) && (!allowedProductIds || allowedProductIds.has(String(i.product_id))) && isKdsStationItemAllowed(stationIds, stationRoutingCategoryIds, order.kitchen_station_id, i.category_id, order.kitchen_station_id ? stationScope.categoryIdsByStation[String(order.kitchen_station_id)] : undefined, stationScope.hasUnrestrictedStation)) diff --git a/main/routes/order-items.ts b/main/routes/order-items.ts index cd920d27..3b71175a 100644 --- a/main/routes/order-items.ts +++ b/main/routes/order-items.ts @@ -91,7 +91,7 @@ router.patch('/:id/status', requireKdsEnabled, (req: Request, res: Response) => if (item.status === 'void_adjustment') { throw new Error('IMMUTABLE_KDS_ITEM'); } - if (item.status === 'completed' || item.status === 'cancelled') { + if (item.status === 'completed' || item.status === 'cancelled' || item.status === 'refunded') { throw new Error('TERMINAL_KDS_ITEM'); } @@ -114,7 +114,7 @@ router.patch('/:id/status', requireKdsEnabled, (req: Request, res: Response) => } const updateResult = expectedStatus === undefined - ? db.prepare("UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled')").run(status, now(), itemId) + ? db.prepare("UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled', 'refunded')").run(status, now(), itemId) : db.prepare('UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status = ?').run(status, now(), itemId, expectedStatus); if (updateResult.changes !== 1) throw new Error('STATUS_CONFLICT'); @@ -127,7 +127,7 @@ router.patch('/:id/status', requireKdsEnabled, (req: Request, res: Response) => WHERE oi.order_id = ? `).all(item.order_id) as any[]; const visibleItems = rawItems - .filter((row) => !['completed', 'cancelled', 'void_adjustment'].includes(row.status)) + .filter((row) => !['completed', 'cancelled', 'void_adjustment', 'refunded'].includes(row.status)) .filter((row) => row.status !== 'voided' || isVoidedItemKdsVisible(row.voided_at)) .filter((row) => categoryIds.length === 0 || (row.category_id && categoryIds.includes(String(row.category_id)))) .filter((row) => stationIds.length === 0 || isKdsStationItemAllowed(stationIds, stationRoutingCategoryIds, orderStationId, row.category_id, orderStationId ? stationScope.categoryIdsByStation[String(orderStationId)] : undefined, stationScope.hasUnrestrictedStation)); diff --git a/main/routes/orders-validation.ts b/main/routes/orders-validation.ts index 257749e2..7ef23490 100644 --- a/main/routes/orders-validation.ts +++ b/main/routes/orders-validation.ts @@ -28,3 +28,25 @@ export function validateOrderNotes(db: any, notes: string | null | undefined): v export function validateItemNotes(db: any, notes: string | null | undefined): void { validateNoteLength(db, 'max_item_notes_length', DEFAULT_MAX_ITEM_NOTES_LENGTH, notes, 'Item notes'); } + +export function validateProductQuantity( + product: { name?: string; sale_unit?: string; allow_fractional_quantity?: boolean | number; weight_precision?: number }, + quantity: unknown, +): asserts quantity is number { + const productName = product.name || 'product'; + if (typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity <= 0) { + throw Object.assign(new Error(`Invalid quantity for ${productName}: must be a positive number`), { statusCode: 400 }); + } + if (Number.isInteger(quantity)) return; + if (!['kg', 'g', 'lb'].includes(product.sale_unit || 'each') || Number(product.allow_fractional_quantity) !== 1) { + throw Object.assign(new Error(`Invalid quantity for ${productName}: fractional quantities are not allowed`), { statusCode: 400 }); + } + + const precision = Number.isInteger(product.weight_precision) + ? Math.min(Math.max(Number(product.weight_precision), 0), 4) + : 3; + const scale = 10 ** precision; + if (Math.abs(quantity * scale - Math.round(quantity * scale)) > 1e-8) { + throw Object.assign(new Error(`Invalid quantity for ${productName}: use at most ${precision} decimal places`), { statusCode: 400 }); + } +} diff --git a/main/routes/orders.ts b/main/routes/orders.ts index 430eb8e2..c7497a9d 100644 --- a/main/routes/orders.ts +++ b/main/routes/orders.ts @@ -11,7 +11,7 @@ import { import { applyPayableRounding } from '../services/tax-engine'; import { notifyKdsUpdate, notifyOrderUpdated } from '../services/kds'; import { cloudSync } from '../services/cloud-sync'; -import { validateOrderNotes, validateItemNotes } from './orders-validation'; +import { validateOrderNotes, validateItemNotes, validateProductQuantity } from './orders-validation'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; import expressRateLimit from 'express-rate-limit'; @@ -506,9 +506,7 @@ router.post('/', orderWriteRateLimit, requireRole(...ROLE_ACCESS.sales), (req: R const itemDiscount = 0; // Validate quantity and price - if (!quantity || quantity <= 0 || !Number.isFinite(quantity)) { - throw new Error(`Invalid quantity for ${product.name}: must be a positive number`); - } + validateProductQuantity(product, quantity); if (unitPrice < 0 || !Number.isFinite(unitPrice)) { throw new Error(`Invalid price for ${product.name}: must be a non-negative number`); } @@ -728,9 +726,7 @@ router.post('/:id/items', orderWriteRateLimit, requireRole(...ROLE_ACCESS.sales) const itemDiscount = 0; // Validate quantity and price - if (!quantity || quantity <= 0 || !Number.isFinite(quantity)) { - throw new Error(`Invalid quantity for ${product.name}: must be a positive number`); - } + validateProductQuantity(product, quantity); if (unitPrice < 0 || !Number.isFinite(unitPrice)) { throw new Error(`Invalid price for ${product.name}: must be a non-negative number`); } @@ -1005,7 +1001,7 @@ router.patch('/:id/status', orderWriteRateLimit, requireRole(...ROLE_ACCESS.orde // Select only items eligible for restocking (exclude already cancelled, voided, or accounting adjustments) const eligibleItems = db.prepare(` SELECT * FROM order_items - WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment') + WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded') `).all(req.params.id) as any[]; for (const item of eligibleItems) { @@ -1018,7 +1014,7 @@ router.patch('/:id/status', orderWriteRateLimit, requireRole(...ROLE_ACCESS.orde db.prepare(` UPDATE order_items SET status = 'cancelled', updated_at = ? - WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment') + WHERE order_id = ? AND status NOT IN ('cancelled', 'voided', 'void_adjustment', 'refunded') `).run(nowStr, req.params.id); db.prepare('UPDATE orders SET status = ?, cancelled_at = ?, cancellation_reason = ?, updated_at = ? WHERE id = ?') diff --git a/main/routes/products.ts b/main/routes/products.ts index 0a367824..4b2f7311 100644 --- a/main/routes/products.ts +++ b/main/routes/products.ts @@ -270,6 +270,7 @@ function loadProductRelationsBatch(db: any, products: any[]) { } const VALID_TAX_BEHAVIORS = ['country_default', 'inclusive', 'exclusive', 'exempt']; +const VALID_SALE_UNITS = ['each', 'kg', 'g', 'lb'] as const; const router = Router(); @@ -312,6 +313,7 @@ function serializeProduct(product: any): any { ...product, is_active: toBoolean(product.is_active), track_inventory: toBoolean(product.track_inventory), + allow_fractional_quantity: toBoolean(product.allow_fractional_quantity), has_image: toBoolean(product.has_image), category: serializeCategory(product.category), addon_groups: Array.isArray(product.addon_groups) ? product.addon_groups.map(serializeAddonGroup) : product.addon_groups, @@ -340,6 +342,35 @@ function validateProductNumericFields(values: Record, requirePr return null; } +function normalizeSaleUnit(value: unknown): typeof VALID_SALE_UNITS[number] { + return VALID_SALE_UNITS.includes(value as any) ? value as typeof VALID_SALE_UNITS[number] : 'each'; +} + +function validateWeightedProductFields( + values: Record, + current?: { sale_unit?: string; allow_fractional_quantity?: boolean | number }, +): string | null { + if (values.sale_unit !== undefined && !VALID_SALE_UNITS.includes(values.sale_unit as any)) { + return `sale_unit must be one of: ${VALID_SALE_UNITS.join(', ')}`; + } + if (values.allow_fractional_quantity !== undefined && typeof values.allow_fractional_quantity !== 'boolean') { + return 'allow_fractional_quantity must be a boolean'; + } + if (values.weight_precision !== undefined) { + if (!Number.isSafeInteger(values.weight_precision) || (values.weight_precision as number) < 0 || (values.weight_precision as number) > 4) { + return 'weight_precision must be an integer between 0 and 4'; + } + } + const effectiveSaleUnit = values.sale_unit !== undefined ? values.sale_unit : current?.sale_unit ?? 'each'; + const effectiveAllowFractional = values.allow_fractional_quantity !== undefined + ? values.allow_fractional_quantity + : Number(current?.allow_fractional_quantity) === 1; + if (effectiveSaleUnit === 'each' && effectiveAllowFractional === true) { + return 'allow_fractional_quantity requires a weighted sale_unit'; + } + return null; +} + function validateTaxCategoryId(categoryId: unknown): string | null { if (categoryId === null || categoryId === undefined || categoryId === '') return null; if (typeof categoryId !== 'string') return 'tax_category_id must be a string or null'; @@ -431,6 +462,7 @@ router.get('/', (req: Request, res: Response) => { try { const db = getDatabase(); let query = `SELECT p.id, p.category_id, p.name, p.description, p.price, p.cost, p.sku, p.barcode, + p.sale_unit, p.allow_fractional_quantity, p.weight_precision, p.is_active, p.sort_order, p.track_inventory, p.stock_quantity, p.low_stock_threshold, p.tax_type, p.tax_rate, p.tax_category_id, p.tax_behavior, p.cb_percent, p.tags, p.deleted_at, p.created_at, p.updated_at, CASE WHEN p.image_url IS NULL OR p.image_url = '' THEN 0 ELSE 1 END AS has_image @@ -706,6 +738,7 @@ router.post('/', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: R try { const { category_id, name, sku, barcode, description, price, cost_price, + sale_unit, allow_fractional_quantity, weight_precision, tax_category_id, tax_behavior, track_inventory, stock_quantity, low_stock_threshold, is_active, image_url, sort_order, cb_percent, tags, addon_group_ids } = req.body; @@ -717,6 +750,8 @@ router.post('/', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: R } const numericError = validateProductNumericFields(req.body, true); if (numericError) return res.status(400).json({ error: numericError }); + const weightedFieldError = validateWeightedProductFields(req.body); + if (weightedFieldError) return res.status(400).json({ error: weightedFieldError }); if (cb_percent !== undefined && cb_percent !== null) { if (typeof cb_percent !== 'number' || !Number.isFinite(cb_percent) || cb_percent < 0 || cb_percent > 100) { @@ -767,11 +802,13 @@ router.post('/', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: R const insertProduct = db.transaction(() => { db.prepare(` INSERT INTO products (id, category_id, name, sku, barcode, description, price, cost, + sale_unit, allow_fractional_quantity, weight_precision, tax_type, tax_rate, tax_category_id, tax_behavior, track_inventory, stock_quantity, low_stock_threshold, is_active, image_url, sort_order, cb_percent, tags, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, normalizeNullableString(category_id), productName, normalizeNullableString(sku), normalizedBarcode, normalizeNullableString(description), price, cost_price || 0, + normalizeSaleUnit(sale_unit), allow_fractional_quantity ? 1 : 0, weight_precision ?? 3, 'none', 0, normalizeNullableString(tax_category_id), tax_behavior || 'country_default', track_inventory ? 1 : 0, stock_quantity || 0, low_stock_threshold || 0, is_active !== false ? 1 : 0, normalizeNullableString(image_url), @@ -799,13 +836,17 @@ router.post('/', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: R router.put('/:id', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: Response) => { try { const db = getDatabase(); - const product = db.prepare('SELECT * FROM products WHERE id = ? AND deleted_at IS NULL').get(req.params.id); + const product = db.prepare('SELECT * FROM products WHERE id = ? AND deleted_at IS NULL').get(req.params.id) as { + sale_unit?: string; + allow_fractional_quantity?: number; + } | undefined; if (!product) { return res.status(404).json({ error: 'Product not found' }); } const { category_id, name, sku, barcode, description, price, cost_price, + sale_unit, allow_fractional_quantity, weight_precision, tax_category_id, tax_behavior, track_inventory, stock_quantity, low_stock_threshold, is_active, image_url, sort_order, cb_percent, tags, addon_group_ids } = req.body; @@ -818,6 +859,8 @@ router.put('/:id', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: const numericError = validateProductNumericFields(req.body, false); if (numericError) return res.status(400).json({ error: numericError }); + const weightedFieldError = validateWeightedProductFields(req.body, product); + if (weightedFieldError) return res.status(400).json({ error: weightedFieldError }); if (tax_behavior !== undefined && tax_behavior !== null && !VALID_TAX_BEHAVIORS.includes(tax_behavior)) { return res.status(400).json({ error: `tax_behavior must be one of: ${VALID_TAX_BEHAVIORS.join(', ')}` }); @@ -867,6 +910,9 @@ router.put('/:id', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: const hasDescription = hasOwn(req.body, 'description'); const hasCostPrice = hasOwn(req.body, 'cost_price'); const hasTags = hasOwn(req.body, 'tags'); + const hasSaleUnit = hasOwn(req.body, 'sale_unit'); + const hasAllowFractionalQuantity = hasOwn(req.body, 'allow_fractional_quantity'); + const hasWeightPrecision = hasOwn(req.body, 'weight_precision'); const addonGroupValidation = validateAddonGroupIds(db, addon_group_ids); if (addonGroupValidation.error) { @@ -882,6 +928,9 @@ router.put('/:id', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: name = CASE WHEN @has_name = 1 THEN @name ELSE name END, sku = CASE WHEN @has_sku = 1 THEN @sku ELSE sku END, barcode = CASE WHEN @has_barcode = 1 THEN @barcode ELSE barcode END, + sale_unit = CASE WHEN @has_sale_unit = 1 THEN @sale_unit ELSE sale_unit END, + allow_fractional_quantity = CASE WHEN @has_allow_fractional_quantity = 1 THEN @allow_fractional_quantity ELSE allow_fractional_quantity END, + weight_precision = CASE WHEN @has_weight_precision = 1 THEN @weight_precision ELSE weight_precision END, description = CASE WHEN @has_description = 1 THEN @description ELSE description END, price = COALESCE(@price, price), cost = CASE WHEN @has_cost = 1 THEN @cost ELSE cost END, @@ -908,6 +957,12 @@ router.put('/:id', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: sku: normalizeNullableString(sku), has_barcode: hasBarcode ? 1 : 0, barcode: normalizedBarcode, + has_sale_unit: hasSaleUnit ? 1 : 0, + sale_unit: normalizeSaleUnit(sale_unit), + has_allow_fractional_quantity: hasAllowFractionalQuantity ? 1 : 0, + allow_fractional_quantity: allow_fractional_quantity ? 1 : 0, + has_weight_precision: hasWeightPrecision ? 1 : 0, + weight_precision: weight_precision ?? null, has_description: hasDescription ? 1 : 0, description: normalizeNullableString(description), price: price ?? null, diff --git a/main/routes/refunds.ts b/main/routes/refunds.ts new file mode 100644 index 00000000..f99fffd8 --- /dev/null +++ b/main/routes/refunds.ts @@ -0,0 +1,144 @@ +import { createHash } from 'crypto'; +import { Router, Request, Response } from 'express'; +import { getDatabase, now, withTxn } from '../db'; +import { requireRole } from '../middleware/security'; +import { ROLE_ACCESS } from '../../shared/role-permissions'; +import { checkPinRateLimit } from './orders'; +import { createRefund, RefundRequest } from '../services/refund'; + +const router = Router(); +const MAX_IDEMPOTENCY_KEY_LENGTH = 128; +const MAX_REASON_LENGTH = 500; + +function refundIdempotencyKey(req: Request): string | null { + const supplied = req.get('Idempotency-Key')?.trim(); + if (!supplied) return null; + if (supplied.length > MAX_IDEMPOTENCY_KEY_LENGTH || !/^[\x21-\x7e]+$/.test(supplied)) { + throw Object.assign(new Error('Idempotency-Key is invalid or too long'), { statusCode: 400 }); + } + return supplied; +} + +function refundRequestHash(billId: string, body: any): string { + return createHash('sha256').update(JSON.stringify({ + billId, + order_item_id: body.order_item_id ?? null, + amount: body.amount ?? null, + method: body.method ?? null, + reason: body.reason ?? null, + shift_id: body.shift_id ?? null, + })).digest('hex'); +} + +function refundAmountCents(value: unknown): number { + if (typeof value !== 'number' && typeof value !== 'string') { + throw Object.assign(new Error('Refund amount must be a finite number greater than zero'), { statusCode: 400 }); + } + const text = String(value).trim(); + if (!/^\d+(?:\.\d{1,2})?$/.test(text)) { + throw Object.assign(new Error('Refund amount must be a finite number greater than zero with at most 2 decimal places'), { statusCode: 400 }); + } + const parsed = Number(text); + const cents = Math.round(parsed * 100); + if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isSafeInteger(cents)) { + throw Object.assign(new Error('Refund amount must be a finite number greater than zero'), { statusCode: 400 }); + } + return cents; +} + +router.post('/', requireRole(...ROLE_ACCESS.ownerManagerCashier), (req: Request, res: Response) => { + try { + const body = req.body || {}; + const billId = body.bill_id; + if (billId === undefined || billId === null || billId === '') { + return res.status(400).json({ error: 'bill_id is required' }); + } + const orderItemId = body.order_item_id !== undefined && body.order_item_id !== null ? Number(body.order_item_id) : null; + if (orderItemId !== null && !Number.isSafeInteger(orderItemId)) { + return res.status(400).json({ error: 'order_item_id must be an integer' }); + } + let amountCents: number | undefined; + if (body.amount !== undefined && body.amount !== null) { + amountCents = refundAmountCents(body.amount); + } else if (orderItemId === null) { + return res.status(400).json({ error: 'amount is required unless order_item_id is given' }); + } + if (typeof body.reason === 'string' && body.reason.length > MAX_REASON_LENGTH) { + return res.status(400).json({ error: 'reason is too long' }); + } + + const idempotencyKey = refundIdempotencyKey(req); + const requestHash = idempotencyKey ? refundRequestHash(String(billId), body) : undefined; + + const db = getDatabase(); + const userId = String((req as any).user.userId); + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + + const refundRequest: RefundRequest = { + billId, + orderItemId, + amountCents, + method: body.method, + reason: body.reason ?? null, + shiftId: body.shift_id ?? null, + overridePin: body.override_pin, + managerId: body.manager_id || body.user_id, + createdByUserId: userId, + clientIp, + checkPinRateLimit, + idempotencyKey, + requestHash, + }; + + const result = withTxn(() => createRefund(db, refundRequest)); + res.status(201).json(result); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to process refund' }); + } +}); + +router.get('/', requireRole(...ROLE_ACCESS.ownerManagerCashier), (req: Request, res: Response) => { + try { + const db = getDatabase(); + let query = 'SELECT * FROM refunds WHERE 1=1'; + let countQuery = 'SELECT COUNT(*) as count FROM refunds WHERE 1=1'; + const params: any[] = []; + + if (req.query.bill_id) { + query += ' AND bill_id = ?'; + countQuery += ' AND bill_id = ?'; + params.push(req.query.bill_id); + } + + const requestedLimit = req.query.limit !== undefined ? Number(req.query.limit) : 50; + if (!Number.isInteger(requestedLimit) || requestedLimit < 1) { + return res.status(400).json({ error: 'limit must be a positive integer' }); + } + const limit = Math.min(requestedLimit, 500); + const offset = req.query.offset !== undefined ? Number(req.query.offset) : 0; + if (!Number.isInteger(offset) || offset < 0) { + return res.status(400).json({ error: 'offset must be a non-negative integer' }); + } + + query += ' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?'; + const pageParams = [...params, limit, offset]; + + const refunds = db.prepare(query).all(...pageParams); + const total = Number((db.prepare(countQuery).get(...params) as any)?.count || 0); + res.json({ + refunds, + pagination: { + limit, + per_page: limit, + offset, + total, + next_offset: offset + refunds.length < total ? offset + refunds.length : null, + has_more: offset + refunds.length < total, + }, + }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to list refunds' }); + } +}); + +export { router as refundRoutes }; diff --git a/main/routes/reports.ts b/main/routes/reports.ts index 8d622874..f7efc307 100644 --- a/main/routes/reports.ts +++ b/main/routes/reports.ts @@ -68,7 +68,7 @@ function paymentMethodBreakdown( WHERE b.payment_details IS NOT NULL AND b.created_at < ? AND (b.paid_at IS NULL OR b.paid_at >= ?) - AND (? = 0 OR b.payment_status = 'paid') + AND (? = 0 OR b.paid_at IS NOT NULL) AND json_type(je.value) = 'object' ), normalized AS ( SELECT @@ -81,6 +81,9 @@ function paymentMethodBreakdown( datetime(NULLIF(created_at, '')) ) AS payment_time FROM payment_lines + UNION ALL + SELECT method, NULL, -(amount_cents / 100.0), datetime(created_at) + FROM refunds ) SELECT COALESCE(pm.name, normalized.method) AS method, COUNT(*) AS count, COALESCE(SUM(CASE WHEN typeof(amount) IN ('integer', 'real') THEN amount ELSE 0 END), 0) AS total @@ -109,9 +112,10 @@ router.get('/daily-stats', requireRole(...ROLE_ACCESS.ownerManager), (req: Reque const today = utcTodayDate(); const [start, end] = utcDayBounds(today); const salesToday = db.prepare(` - SELECT COALESCE(SUM(paid_amount), 0) AS sales - FROM bills WHERE created_at >= ? AND created_at < ? - `).get(start, end) as { sales: number }; + SELECT + COALESCE((SELECT SUM(paid_amount) FROM bills WHERE paid_at >= ? AND paid_at < ?), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE created_at >= ? AND created_at < ?), 0) AS sales + `).get(start, end, start, end) as { sales: number }; const paymentMethodsToday = paymentMethodBreakdown(db, today) as { total: number }[]; const runningOrders = db.prepare(` @@ -154,9 +158,10 @@ router.get('/summary', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, const billsToday = db.prepare(` SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total, - COALESCE(SUM(paid_amount), 0) as collected + COALESCE((SELECT SUM(paid_amount) FROM bills WHERE paid_at >= ? AND paid_at < ?), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE created_at >= ? AND created_at < ?), 0) as collected FROM bills WHERE created_at >= ? AND created_at < ? - `).get(start, end) as { count: number; total: number; collected: number }; + `).get(start, end, start, end, start, end) as { count: number; total: number; collected: number }; const paymentMethodsToday = paymentMethodBreakdown(db, date); const customersToday = db.prepare(` @@ -425,10 +430,12 @@ router.get('/insights', requireRole(...ROLE_ACCESS.ownerManager), (req: Request, // AOV — same revenue basis ("paid bills") as the existing daily-stats tile. const revenue = db.prepare(` - SELECT COUNT(*) as billCount, COALESCE(SUM(paid_amount), 0) as total + SELECT COUNT(*) as billCount, + COALESCE(SUM(paid_amount), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE created_at >= ?), 0) as total FROM bills - WHERE payment_status = 'paid' AND paid_at >= ? - `).get(windowStart) as { billCount: number; total: number }; + WHERE paid_at >= ? + `).get(windowStart, windowStart) as { billCount: number; total: number }; const aov = revenue.billCount > 0 ? revenue.total / revenue.billCount : 0; // Kitchen velocity — substitutes for "best cook", which isn't derivable: diff --git a/main/services/cloud-sync.ts b/main/services/cloud-sync.ts index be957ec1..b87afdfc 100644 --- a/main/services/cloud-sync.ts +++ b/main/services/cloud-sync.ts @@ -1072,10 +1072,11 @@ export class CloudSyncService { // instead of date() on every row. const [ts, te] = utcDayBounds(utcTodayDate()); const todaySales = db.prepare(` - SELECT COALESCE(SUM(total), 0) as total, COUNT(*) as count - FROM bills - WHERE payment_status = 'paid' AND paid_at >= ? AND paid_at < ? - `).get(ts, te) as { total: number; count: number }; + SELECT + COALESCE((SELECT SUM(paid_amount) FROM bills WHERE paid_at >= ? AND paid_at < ?), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE created_at >= ? AND created_at < ?), 0) as total, + (SELECT COUNT(*) FROM bills WHERE paid_at >= ? AND paid_at < ?) as count + `).get(ts, te, ts, te, ts, te) as { total: number; count: number }; return { pos_hash: cfg.pos_hash, pos_id: cfg.pos_id || null, @@ -1625,24 +1626,25 @@ export class CloudSyncService { COALESCE(SUM(subtotal), 0) as subtotal, COALESCE(SUM(tax_amount), 0) as tax_amount, COALESCE(SUM(discount_amount), 0) as discount_amount, - COALESCE(SUM(paid_amount), 0) as paid_amount + COALESCE(SUM(paid_amount), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE created_at >= ? AND created_at <= ?), 0) as paid_amount FROM bills - WHERE payment_status = 'paid' - AND COALESCE(paid_at, created_at) >= ? - AND COALESCE(paid_at, created_at) <= ? - `).get(range.from, range.to); + WHERE paid_at >= ? AND paid_at <= ? + `).get(range.from, range.to, range.from, range.to); const byDay = db.prepare(` - SELECT date(COALESCE(paid_at, created_at)) as date, - COUNT(*) as bill_count, - COALESCE(SUM(total), 0) as gross_sales - FROM bills - WHERE payment_status = 'paid' - AND COALESCE(paid_at, created_at) >= ? - AND COALESCE(paid_at, created_at) <= ? - GROUP BY date(COALESCE(paid_at, created_at)) + WITH events AS ( + SELECT date(paid_at) as date, 1 as bill_count, paid_amount as gross_sales + FROM bills WHERE paid_at >= ? AND paid_at <= ? + UNION ALL + SELECT date(created_at), 0, -(amount_cents / 100.0) + FROM refunds WHERE created_at >= ? AND created_at <= ? + ) + SELECT date, SUM(bill_count) as bill_count, COALESCE(SUM(gross_sales), 0) as gross_sales + FROM events + GROUP BY date ORDER BY date ASC - `).all(range.from, range.to); + `).all(range.from, range.to, range.from, range.to); const topItems = db.prepare(` SELECT oi.product_id, oi.product_name, @@ -1650,7 +1652,7 @@ export class CloudSyncService { COALESCE(SUM(CASE WHEN b.split_group_id IS NULL THEN oi.total ELSE oi.total * bi.quantity / oi.quantity END), 0) as total FROM bills b JOIN orders o ON o.id = b.order_id JOIN order_items oi ON oi.order_id = o.id LEFT JOIN bill_items bi ON bi.bill_id = b.id AND bi.order_item_id = oi.id - WHERE b.payment_status = 'paid' + WHERE b.paid_at IS NOT NULL AND (b.split_group_id IS NULL OR bi.bill_id IS NOT NULL) AND COALESCE(b.paid_at, b.created_at) >= ? AND COALESCE(b.paid_at, b.created_at) <= ? @@ -1671,19 +1673,20 @@ export class CloudSyncService { const range = dateRange({ from: payload?.date, to: payload?.date }); const db = getDatabase(); const totals = db.prepare(` - SELECT COUNT(*) AS bill_count, COALESCE(SUM(total), 0) AS total_sales, + SELECT COUNT(*) AS bill_count, COALESCE(SUM(paid_amount), 0) + - COALESCE((SELECT SUM(amount_cents) / 100.0 FROM refunds WHERE date(created_at) BETWEEN date(?) AND date(?)), 0) AS total_sales, COALESCE(SUM(tax_amount), 0) AS total_tax, COALESCE(SUM(discount_amount), 0) AS total_discount FROM bills - WHERE payment_status = 'paid' AND date(COALESCE(paid_at, created_at)) BETWEEN date(?) AND date(?) - `).get(range.from, range.to) as any; + WHERE paid_at IS NOT NULL AND date(paid_at) BETWEEN date(?) AND date(?) + `).get(range.from, range.to, range.from, range.to) as any; const topItems = db.prepare(` SELECT oi.product_name AS name, COALESCE(SUM(CASE WHEN b.split_group_id IS NULL THEN oi.quantity ELSE bi.quantity END), 0) AS qty, COALESCE(SUM(CASE WHEN b.split_group_id IS NULL THEN oi.total ELSE oi.total * bi.quantity / oi.quantity END), 0) AS revenue, COALESCE(AVG(oi.unit_price), 0) AS price FROM bills b JOIN orders o ON o.id = b.order_id JOIN order_items oi ON oi.order_id = o.id LEFT JOIN bill_items bi ON bi.bill_id = b.id AND bi.order_item_id = oi.id - WHERE b.payment_status = 'paid' AND date(COALESCE(b.paid_at, b.created_at)) BETWEEN date(?) AND date(?) + WHERE b.paid_at IS NOT NULL AND date(b.paid_at) BETWEEN date(?) AND date(?) AND (b.split_group_id IS NULL OR bi.bill_id IS NOT NULL) GROUP BY oi.product_id, oi.product_name ORDER BY revenue DESC LIMIT 5 `).all(range.from, range.to); @@ -1700,12 +1703,16 @@ export class CloudSyncService { private hourlyReport(payload?: Record) { const range = dateRange({ from: payload?.date, to: payload?.date }); const rows = getDatabase().prepare(` - SELECT strftime('%H', COALESCE(paid_at, created_at)) AS hour, - COALESCE(SUM(total), 0) AS sales, COUNT(*) AS bills - FROM bills - WHERE payment_status = 'paid' AND date(COALESCE(paid_at, created_at)) BETWEEN date(?) AND date(?) - GROUP BY hour ORDER BY hour - `).all(range.from, range.to) as any[]; + WITH events AS ( + SELECT strftime('%H', paid_at) AS hour, paid_amount AS sales, 1 AS bills + FROM bills WHERE paid_at IS NOT NULL AND date(paid_at) BETWEEN date(?) AND date(?) + UNION ALL + SELECT strftime('%H', created_at), -(amount_cents / 100.0), 0 + FROM refunds WHERE date(created_at) BETWEEN date(?) AND date(?) + ) + SELECT hour, COALESCE(SUM(sales), 0) AS sales, SUM(bills) AS bills + FROM events GROUP BY hour ORDER BY hour + `).all(range.from, range.to, range.from, range.to) as any[]; const byHour = new Map(rows.map((row) => [String(row.hour).padStart(2, '0'), row])); return { hours: Array.from({ length: 24 }, (_, hour) => { const row = byHour.get(String(hour).padStart(2, '0')); @@ -1722,7 +1729,7 @@ export class CloudSyncService { COALESCE(SUM(CASE WHEN b.split_group_id IS NULL THEN oi.total ELSE oi.total * bi.quantity / oi.quantity END), 0) AS revenue FROM bills b JOIN orders o ON o.id = b.order_id JOIN order_items oi ON oi.order_id = o.id LEFT JOIN bill_items bi ON bi.bill_id = b.id AND bi.order_item_id = oi.id - WHERE b.payment_status = 'paid' AND date(COALESCE(b.paid_at, b.created_at)) BETWEEN date(?) AND date(?) + WHERE b.paid_at IS NOT NULL AND date(b.paid_at) BETWEEN date(?) AND date(?) AND (b.split_group_id IS NULL OR bi.bill_id IS NOT NULL) GROUP BY oi.product_id, oi.product_name ORDER BY revenue DESC LIMIT ? `).all(range.from, range.to, limit); @@ -1741,13 +1748,20 @@ export class CloudSyncService { private paymentBreakdown(range: DateRange) { return getDatabase().prepare(` - SELECT COALESCE(pm.name, json_extract(je.value, '$.method')) AS method, - COUNT(*) AS count, COALESCE(SUM(json_extract(je.value, '$.amount')), 0) AS amount - FROM bills b, json_each(b.payment_details) je - LEFT JOIN payment_methods pm ON pm.id = CAST(json_extract(je.value, '$.payment_method_id') AS INTEGER) - WHERE b.payment_details IS NOT NULL - AND date(COALESCE(json_extract(je.value, '$.timestamp'), b.paid_at, b.created_at)) BETWEEN date(?) AND date(?) - GROUP BY COALESCE(pm.name, json_extract(je.value, '$.method')) ORDER BY amount DESC + WITH entries AS ( + SELECT COALESCE(pm.name, json_extract(je.value, '$.method')) AS method, + json_extract(je.value, '$.amount') AS amount, + COALESCE(json_extract(je.value, '$.timestamp'), b.paid_at, b.created_at) AS occurred_at + FROM bills b, json_each(b.payment_details) je + LEFT JOIN payment_methods pm ON pm.id = CAST(json_extract(je.value, '$.payment_method_id') AS INTEGER) + WHERE b.payment_details IS NOT NULL + UNION ALL + SELECT method, -(amount_cents / 100.0), created_at FROM refunds + ) + SELECT method, COUNT(*) AS count, COALESCE(SUM(amount), 0) AS amount + FROM entries + WHERE date(occurred_at) BETWEEN date(?) AND date(?) + GROUP BY method ORDER BY amount DESC `).all(range.from, range.to); } diff --git a/main/services/kds.ts b/main/services/kds.ts index eebb9400..ef2c1bd6 100644 --- a/main/services/kds.ts +++ b/main/services/kds.ts @@ -406,7 +406,7 @@ function handleStatusUpdate(client: KdsClient, message: any): void { if (existingItem.status === 'void_adjustment') { return { error: 'This bill adjustment cannot be updated from KDS' }; } - if (existingItem.status === 'completed' || existingItem.status === 'cancelled') { + if (existingItem.status === 'completed' || existingItem.status === 'cancelled' || existingItem.status === 'refunded') { return { error: 'This terminal item cannot be updated from KDS' }; } @@ -429,7 +429,7 @@ function handleStatusUpdate(client: KdsClient, message: any): void { } const updateResult = expectedStatus === undefined - ? db.prepare("UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled')").run(status, now(), order_item_id) + ? db.prepare("UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status NOT IN ('voided', 'void_adjustment', 'completed', 'cancelled', 'refunded')").run(status, now(), order_item_id) : db.prepare('UPDATE order_items SET status = ?, updated_at = ? WHERE id = ? AND status = ?').run(status, now(), order_item_id, expectedStatus); if (updateResult.changes !== 1) { return { error: 'Item status changed; refresh and try again' }; @@ -536,7 +536,7 @@ function sendActiveOrders(ws: WebSocket, categoryIds: string[], stationIds: stri const allVisibleItems = (orders as any[]) .flatMap((o: any) => itemsByOrder[o.id] || []) .filter((i: any) => i.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(i.status) + && !['completed', 'cancelled', 'refunded'].includes(i.status) && (i.status !== 'voided' || isVoidedItemKdsVisible(i.voided_at)) && isKdsStationItemAllowed(stationIds, stationRoutingCategoryIds, (orders as any[]).find((order) => order.id === i.order_id)?.kitchen_station_id, i.category_id, (orders as any[]).find((order) => order.id === i.order_id)?.kitchen_station_id ? stationScope.categoryIdsByStation[String((orders as any[]).find((order) => order.id === i.order_id)?.kitchen_station_id)] : undefined, stationScope.hasUnrestrictedStation)); const itemsWithAddons = attachEffectiveAddons(db, allVisibleItems.map(parseItemJson) as any[]); @@ -547,7 +547,7 @@ function sendActiveOrders(ws: WebSocket, categoryIds: string[], stationIds: stri // item) and age voided items off the board after their grace period. const visibleItems = (itemsByOrder[order.id] || []) .filter((i: any) => i.status !== 'void_adjustment' - && !['completed', 'cancelled'].includes(i.status) + && !['completed', 'cancelled', 'refunded'].includes(i.status) && (i.status !== 'voided' || isVoidedItemKdsVisible(i.voided_at)) && isKdsStationItemAllowed(stationIds, stationRoutingCategoryIds, order.kitchen_station_id, i.category_id, order.kitchen_station_id ? stationScope.categoryIdsByStation[String(order.kitchen_station_id)] : undefined, stationScope.hasUnrestrictedStation)) .map((i: any) => addonsByItemId.get(i.id) || i); @@ -572,7 +572,7 @@ function sendActiveOrders(ws: WebSocket, categoryIds: string[], stationIds: stri JOIN orders o ON oi.order_id = o.id LEFT JOIN tables t ON o.table_id = t.id WHERE ${activeOrdersCondition()} - AND oi.status NOT IN ('completed', 'cancelled', 'void_adjustment') + AND oi.status NOT IN ('completed', 'cancelled', 'void_adjustment', 'refunded') AND (oi.status != 'voided' OR oi.voided_at IS NULL OR oi.voided_at > ?) `; const countParams: any[] = [voidedCutoff]; diff --git a/main/services/refund.ts b/main/services/refund.ts new file mode 100644 index 00000000..8690e368 --- /dev/null +++ b/main/services/refund.ts @@ -0,0 +1,206 @@ +/** + * Refund processing (#278): bill-level cash-back and item-level "already + * prepared, must be pulled off a paid bill" refunds. + * + * Item-level refunds mirror the existing in-progress item-void mechanism + * (main/routes/index.ts, PATCH /api/orders/:orderId/items/:itemId/cancel) + * but for a bill that already has payment on it, which that endpoint always + * blocks. Inventory is deliberately not restored — it was already consumed + * when the item was prepared, same rule as the existing void path. + */ +import { getDatabase, now, parseDbTimestamp, verifyPin } from '../db'; +import { invertTaxBreakdown, invertTaxSnapshot } from './tax'; +import { ROLE_ACCESS } from '../../shared/role-permissions'; + +type Database = ReturnType; + +const OWNER_MANAGER_ROLE_PLACEHOLDERS = ROLE_ACCESS.ownerManager.map(() => '?').join(', '); +const REFUND_ITEM_ELIGIBLE_STATUSES = ['preparing', 'ready']; +const REFUND_WINDOW_MS = 2 * 60 * 60 * 1000; +// Kept in sync with the ['cancelled', 'voided', 'void_adjustment'] exclusion +// list used throughout main/routes/bills.ts, main/routes/index.ts, and +// main/routes/orders.ts — 'refunded' is the new terminal item status this +// feature introduces and must be excluded everywhere those are. +export const TERMINAL_ITEM_STATUSES = ['cancelled', 'voided', 'void_adjustment', 'refunded']; + +export interface RefundRequest { + billId: string | number; + orderItemId?: number | null; + amountCents?: number; + method?: string; + reason?: string | null; + shiftId?: string | null; + overridePin: string; + managerId?: string | null; + createdByUserId: string; + clientIp: string; + checkPinRateLimit: (key: string) => boolean; + idempotencyKey?: string | null; + requestHash?: string; +} + +export interface RefundResult { + refund: any; + bill: any; +} + +function httpError(message: string, statusCode: number): Error { + return Object.assign(new Error(message), { statusCode }); +} + +export function getRefundableBalance(db: Database, billId: string | number): { + paidCents: number; + refundedCents: number; + refundableCents: number; +} { + const bill = db.prepare('SELECT paid_amount FROM bills WHERE id = ?').get(billId) as { paid_amount: number } | undefined; + const paidCents = Math.round(Number(bill?.paid_amount || 0) * 100); + const refundedRow = db.prepare('SELECT COALESCE(SUM(amount_cents), 0) AS total FROM refunds WHERE bill_id = ?').get(billId) as { total: number }; + const refundedCents = Number(refundedRow.total || 0); + return { paidCents, refundedCents, refundableCents: paidCents - refundedCents }; +} + +function resolveRefundApprover(db: Database, overridePin: string, managerId?: string | null): { id: string } | null { + if (managerId) { + const candidate = db.prepare(`SELECT * FROM users WHERE id = ? AND pin_hash IS NOT NULL AND role IN (${OWNER_MANAGER_ROLE_PLACEHOLDERS}) AND is_active = 1`).get(managerId, ...ROLE_ACCESS.ownerManager) as any; + if (candidate && verifyPin(candidate.pin_hash, overridePin)) return candidate; + } + const managers = db.prepare(`SELECT * FROM users WHERE pin_hash IS NOT NULL AND role IN (${OWNER_MANAGER_ROLE_PLACEHOLDERS}) AND is_active = 1`).all(...ROLE_ACCESS.ownerManager) as any[]; + for (const user of managers) { + if (verifyPin(user.pin_hash, overridePin)) return user; + } + return null; +} + +/** + * Validates, authorizes, and persists a refund. Must be called from inside + * the caller's withTxn — mirrors applyPaymentBatch's caller contract, where + * the whole function (idempotency lookup included) runs inside one + * transaction (main/routes/bills.ts). + */ +export function createRefund(db: Database, req: RefundRequest): RefundResult { + if (req.idempotencyKey) { + const prior = db.prepare(` + SELECT bill_id, request_hash, response_json + FROM refund_idempotency + WHERE user_id = ? AND idempotency_key = ? + `).get(req.createdByUserId, req.idempotencyKey) as { bill_id: string; request_hash: string; response_json: string } | undefined; + if (prior) { + if (String(prior.bill_id) !== String(req.billId) || prior.request_hash !== req.requestHash) { + throw httpError('Idempotency-Key was already used for a different refund request', 409); + } + try { + return JSON.parse(prior.response_json); + } catch { + throw httpError('Stored refund response is invalid', 500); + } + } + } + + const bill = db.prepare('SELECT * FROM bills WHERE id = ?').get(req.billId) as any; + if (!bill) throw httpError('Bill not found', 404); + const order = db.prepare('SELECT created_at FROM orders WHERE id = ?').get(bill.order_id) as { created_at: string } | undefined; + if (!order) throw httpError('Order not found', 404); + const orderCreatedAt = parseDbTimestamp(order.created_at).getTime(); + if (!Number.isFinite(orderCreatedAt) || Date.now() - orderCreatedAt > REFUND_WINDOW_MS) { + throw httpError('Refund window has expired. Refunds are allowed within 2 hours of order creation.', 409); + } + + let amountCents = req.amountCents; + let item: any = null; + if (req.orderItemId != null) { + item = db.prepare('SELECT * FROM order_items WHERE id = ?').get(req.orderItemId) as any; + if (!item) throw httpError('Order item not found', 404); + if (String(item.order_id) !== String(bill.order_id)) { + throw httpError("Item does not belong to this bill's order", 400); + } + if (bill.split_group_id) { + const allocation = db.prepare(` + SELECT quantity FROM bill_items WHERE bill_id = ? AND order_item_id = ? + `).get(bill.id, item.id) as { quantity: number } | undefined; + if (!allocation) { + throw httpError('Item is not allocated to this split bill', 400); + } + if (Number(allocation.quantity) !== Number(item.quantity)) { + throw httpError('Partially allocated split items cannot be refunded as a whole item', 409); + } + } + if (!REFUND_ITEM_ELIGIBLE_STATUSES.includes(item.status)) { + throw httpError('Item is not eligible for refund', 409); + } + const itemAmountCents = Math.round(Number(item.total) * 100); + if (amountCents !== undefined && amountCents !== itemAmountCents) { + throw httpError("Refund amount does not match the item's refundable total", 400); + } + amountCents = itemAmountCents; + } + + if (amountCents === undefined || !Number.isSafeInteger(amountCents) || amountCents <= 0) { + throw httpError('Refund amount is required', 400); + } + if (!req.method || typeof req.method !== 'string' || req.method.length > 60) { + throw httpError('Refund method is required', 400); + } + + const { paidCents, refundedCents, refundableCents } = getRefundableBalance(db, req.billId); + if (refundableCents <= 0) throw httpError('Bill has nothing left to refund', 400); + if (amountCents > refundableCents) throw httpError('Refund amount exceeds the refundable balance', 400); + + if (!req.overridePin) { + throw httpError('Manager PIN required to process a refund', 400); + } + const rateLimitKey = `pin:${req.clientIp}:refund`; + if (!req.checkPinRateLimit(rateLimitKey)) { + throw httpError('Too many PIN attempts. Try again in 15 minutes.', 429); + } + const approver = resolveRefundApprover(db, req.overridePin, req.managerId); + if (!approver) throw httpError('Invalid manager PIN', 403); + + const timestamp = now(); + + if (item) { + // Mirrors the existing void_adjustment mirrored-negative-row mechanism + // (main/routes/index.ts) verbatim, except the original item transitions + // to 'refunded' (not 'voided') so refund and void stay distinguishable + // in reporting, and inventory is never touched either way. + 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', ?, ?) + `).run( + item.order_id, item.product_id, `Refund: ${item.product_name}`, item.product_sku, + -item.unit_price, item.quantity, -item.subtotal, -(item.tax_amount || 0), + invertTaxBreakdown(item.tax_breakdown), invertTaxSnapshot(item.tax_snapshot), item.tax_type, + -(item.discount_amount || 0), -item.total, + item.variant_selection, item.modifier_selection, timestamp, timestamp, + ); + if (bill.split_group_id) { + db.prepare('INSERT INTO bill_items (bill_id, order_item_id, quantity) VALUES (?, ?, ?)') + .run(bill.id, adjustmentResult.lastInsertRowid, item.quantity); + } + db.prepare("UPDATE order_items SET status = 'refunded', voided_at = ?, updated_at = ? WHERE id = ?") + .run(timestamp, timestamp, item.id); + } + + const insertResult = db.prepare(` + INSERT INTO refunds (bill_id, order_item_id, amount_cents, method, reason, shift_id, approved_by, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(req.billId, req.orderItemId ?? null, amountCents, req.method, req.reason ?? null, req.shiftId ?? null, approver.id, req.createdByUserId, timestamp); + + const newRefundedCents = refundedCents + amountCents; + const paymentStatus = newRefundedCents >= paidCents ? 'refunded' : 'partially_refunded'; + db.prepare('UPDATE bills SET payment_status = ?, updated_at = ? WHERE id = ?').run(paymentStatus, timestamp, req.billId); + + const refund = db.prepare('SELECT * FROM refunds WHERE id = ?').get(insertResult.lastInsertRowid); + const freshBill = db.prepare('SELECT * FROM bills WHERE id = ?').get(req.billId); + const result: RefundResult = { refund, bill: freshBill }; + + if (req.idempotencyKey && req.requestHash) { + db.prepare('INSERT INTO refund_idempotency (user_id, idempotency_key, bill_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?, ?)') + .run(req.createdByUserId, req.idempotencyKey, String(req.billId), req.requestHash, JSON.stringify(result), timestamp); + } + + return result; +} diff --git a/main/services/tax-components.ts b/main/services/tax-components.ts index 6f68f150..5ca744d9 100644 --- a/main/services/tax-components.ts +++ b/main/services/tax-components.ts @@ -171,7 +171,7 @@ function reconcileTotal( function legacyItemComponents(document: TaxDocument): DecimalTaxComponent[] { return (document.items || []).filter( - (item) => item.status !== 'cancelled' && item.status !== 'voided' && item.status !== 'void_adjustment', + (item) => item.status !== 'cancelled' && item.status !== 'voided' && item.status !== 'void_adjustment' && item.status !== 'refunded', ).flatMap((item) => { const snapshot = flattenSnapshots(item.tax_snapshot); return snapshot.present ? [] : flattenLegacyBreakdown(item.tax_breakdown); @@ -249,7 +249,7 @@ export function resolveTaxComponents(document: TaxDocument): DisplayTaxComponent } const activeItems = document.items?.filter( - (item) => item.status !== 'cancelled' && item.status !== 'voided' && item.status !== 'void_adjustment', + (item) => item.status !== 'cancelled' && item.status !== 'voided' && item.status !== 'void_adjustment' && item.status !== 'refunded', ); if (activeItems && activeItems.length > 0) { diff --git a/package.json b/package.json index fc0383a1..e6749531 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "pretest": "bash tests/run-test.sh npm run test:payment-methods-split && bash tests/run-test.sh npm run test:release-regressions", "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": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight", + "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:server-port-collision && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:kds-window-hardening && bash tests/run-test.sh npm run test:electron-api-contract && bash tests/run-test.sh npm run test:titlebar-window-options && bash tests/run-test.sh npm run test:window-readiness && bash tests/run-test.sh npm run test:window-load-retry && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:csp-lan && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:update-channel && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:country-provenance && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:auth-ui-deterministic && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:printer-width-refresh && bash tests/run-test.sh npm run test:printer-migrations && bash tests/run-test.sh npm run test:print-parity && bash tests/run-test.sh npm run test:merchant-print-templates && bash tests/run-test.sh npm run test:merchant-template-transfer && bash tests/run-test.sh npm run test:print-document && bash tests/run-test.sh npm run test:print-kernel && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:print-labels && bash tests/run-test.sh npm run test:locale-chunks && bash tests/run-test.sh npm run test:rtl-foundation && bash tests/run-test.sh npm run test:rtl-setup-auth-settings && bash tests/run-test.sh npm run test:rtl-dashboard-pos-common && bash tests/run-test.sh npm run test:rtl-kds-server-whatsapp && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:country-localization && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:community-tax-packs && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:refunds && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:issue-248-csv && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:issue-244-product-addon-links && bash tests/run-test.sh npm run test:issue-250-catalog-perf && bash tests/run-test.sh npm run test:issue-258-bill-pagination && bash tests/run-test.sh npm run test:issue-265-morocco-profile && bash tests/run-test.sh npm run test:issue-266-currency-symbol-print && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:upgrade-matrix-harness && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:migration-v71-repair && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling && bash tests/run-test.sh npm run test:windows-uninstaller && bash tests/run-test.sh npm run test:shutdown-lifecycle && bash tests/run-test.sh npm run test:redos-hardening && bash tests/run-test.sh npm run test:startup-cache && bash tests/run-test.sh npm run test:service-worker && bash tests/run-test.sh npm run test:issue-389-timezone-override && bash tests/run-test.sh npm run test:issue-390-locale-preference-invariants && bash tests/run-test.sh npm run test:issue-475-picker-highlight", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:url-allowlist && npm run test:static-routes", "test:shutdown-lifecycle": "npm run build && node tests/run-electron-node-test.cjs tests/shutdown-lifecycle.test.ts", "test:redos-hardening": "node tests/run-electron-node-test.cjs tests/redos-hardening.test.ts", @@ -92,6 +92,7 @@ "test:print-document": "ts-node --transpile-only -P tests/tsconfig.json tests/print-document.test.ts", "test:print-kernel": "ts-node --transpile-only -P tests/tsconfig.json tests/print-kernel.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/kernel-purity.test.ts && ts-node --transpile-only -P tests/tsconfig.json tests/print-language-settings.test.ts", "test:cancel-override": "node tests/run-electron-node-test.cjs tests/cancel-override.test.ts && node tests/run-electron-node-test.cjs tests/manager-pin-rate-limit-bypass.test.ts", + "test:refunds": "node tests/run-electron-node-test.cjs tests/refunds.test.ts", "test:kitchen-addons": "node tests/run-electron-node-test.cjs tests/kitchen-addons-parsing.test.ts", "test:order-item-addons": "node tests/run-electron-node-test.cjs tests/order-item-addons.test.ts && node tests/run-electron-node-test.cjs tests/addon-price-integrity.test.ts", "test:issue-125-addon-reads": "node tests/run-electron-node-test.cjs tests/issue-125-addon-read-paths.test.ts", diff --git a/tests/held-orders.test.ts b/tests/held-orders.test.ts index 7282ec9e..bd8c11a6 100644 --- a/tests/held-orders.test.ts +++ b/tests/held-orders.test.ts @@ -23,7 +23,7 @@ Module._load = function (request: string, parent: unknown, isMain: boolean) { const { initTestDb, createApp, startServer, - seedOwnerUser, seedTable, + seedOwnerUser, seedCategory, seedProduct, seedTable, api, assert, assertEqual, closeDatabase, getDatabase, now, } = require('./helpers/test-setup'); @@ -36,6 +36,11 @@ async function main() { const db = initTestDb(); const { authHeader } = seedOwnerUser(db); + seedCategory(db, 'cat-held-365', 'Weighted products'); + seedProduct(db, 'product-mango', 'cat-held-365', 'Mango', 120, { + sale_unit: 'kg', allow_fractional_quantity: true, weight_precision: 3, + }); + seedProduct(db, 'product-each', 'cat-held-365', 'Each item', 10); const app = createApp({ '/api/held-orders': heldOrderRoutes, @@ -89,6 +94,42 @@ async function main() { assertEqual(held.items[0].product.name, 'Latte', 'Items parsed correctly'); console.log(' ✓ GET /held-orders retrieves held order'); + console.log('\n─── Scenario B2: fractional quantities can be held ───'); + const weightedTableId = 'tbl-weighted-365'; + seedTable(db, weightedTableId, 3); + const weightedItems = [{ + id: 'mango-line', + product: { id: 'product-mango', name: 'Mango', price: 120, sale_unit: 'kg', allow_fractional_quantity: true }, + quantity: 1.25, + addons: [], + special_instructions: '', + }]; + const weightedPost = await api(baseUrl, '/api/held-orders', { + method: 'POST', + body: { tableId: weightedTableId, items: weightedItems }, + headers: authHeader, + }); + assertEqual(weightedPost.status, 200, 'POST /held-orders accepts fractional weighted quantity'); + const weightedList = await api(baseUrl, '/api/held-orders', { headers: authHeader }); + const weightedHeld = weightedList.data.orders.find((order: any) => order.tableId === weightedTableId); + assertEqual(weightedHeld?.items[0].quantity, 1.25, 'Fractional quantity survives storage and parsing'); + await api( + baseUrl, + `/api/held-orders/${weightedTableId}?heldOrderId=${encodeURIComponent(weightedPost.data.id)}`, + { method: 'DELETE', headers: authHeader }, + ); + console.log(' ✓ Fractional held-order quantities round-trip'); + + const disallowedFraction = await api(baseUrl, '/api/held-orders', { + method: 'POST', + body: { + tableId: weightedTableId, + items: [{ ...weightedItems[0], product: { id: 'product-each', name: 'Each item', price: 10 }, quantity: 1.25 }], + }, + headers: authHeader, + }); + assertEqual(disallowedFraction.status, 400, 'POST /held-orders rejects fractional quantity for a whole-unit product'); + // ═══════════════════════════════════════════════════════════════════ console.log('\n─── Scenario C: POST /held-orders validates request data ───'); const invalidRequests = [ diff --git a/tests/helpers/test-setup.ts b/tests/helpers/test-setup.ts index d9a3d63a..23eabf48 100644 --- a/tests/helpers/test-setup.ts +++ b/tests/helpers/test-setup.ts @@ -228,6 +228,9 @@ function seedProduct(db: any, id: string, categoryId: string, name: string, pric cb_percent?: number | null; track_inventory?: boolean; stock_quantity?: number; + sale_unit?: 'each' | 'kg' | 'g' | 'lb'; + allow_fractional_quantity?: boolean; + weight_precision?: number; }) { // Most integration fixtures represent taxable menu products. Assign the // Fresh stores use the generic no-tax pack, so test products are @@ -238,8 +241,9 @@ function seedProduct(db: any, id: string, categoryId: string, name: string, pric db.prepare( `INSERT OR IGNORE INTO products ( id, category_id, name, price, tax_type, tax_category_id, tax_behavior, - cb_percent, track_inventory, stock_quantity, is_active, sort_order, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + cb_percent, track_inventory, stock_quantity, sale_unit, allow_fractional_quantity, + weight_precision, is_active, sort_order, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, categoryId, name, price, options?.tax_type || 'none', @@ -248,6 +252,9 @@ function seedProduct(db: any, id: string, categoryId: string, name: string, pric options && Object.prototype.hasOwnProperty.call(options, 'cb_percent') ? options.cb_percent : 0, options?.track_inventory ? 1 : 0, options?.stock_quantity ?? 999, + options?.sale_unit || 'each', + options?.allow_fractional_quantity ? 1 : 0, + options?.weight_precision ?? 3, 1, 1, now(), now() ); } diff --git a/tests/issue-137-barcode.test.ts b/tests/issue-137-barcode.test.ts index 5fd15753..80e0be3a 100644 --- a/tests/issue-137-barcode.test.ts +++ b/tests/issue-137-barcode.test.ts @@ -48,11 +48,22 @@ async function main() { { const res = await api(baseUrl, '/api/products', { method: 'POST', - body: { category_id: 'cat-137', name: 'Water Bottle', price: 20, barcode: '8901234567890' }, + body: { + category_id: 'cat-137', + name: 'Water Bottle', + price: 20, + barcode: '8901234567890', + sale_unit: 'kg', + allow_fractional_quantity: true, + weight_precision: 3, + }, headers: authHeader, }); assertEqual(res.status, 201, `product with barcode created (got ${res.status}, ${JSON.stringify(res.data)})`); assertEqual(res.data.product.barcode, '8901234567890', 'barcode persisted on create'); + assertEqual(res.data.product.sale_unit, 'kg', 'weighted sale unit persisted on create'); + assertEqual(res.data.product.allow_fractional_quantity, true, 'fractional quantity flag serialized as boolean'); + assertEqual(res.data.product.weight_precision, 3, 'weight precision persisted on create'); createdId = res.data.product.id; } @@ -114,11 +125,14 @@ async function main() { const res = await api(baseUrl, `/api/products/${updateTargetId}`, { method: 'PUT', - body: { barcode: '7501234567891' }, + body: { barcode: '7501234567891', sale_unit: 'g', allow_fractional_quantity: true, weight_precision: 0 }, headers: authHeader, }); assertEqual(res.status, 200, 'E: update with a new barcode succeeds'); assertEqual(res.data.product.barcode, '7501234567891', 'E: barcode set via update'); + assertEqual(res.data.product.sale_unit, 'g', 'E: weighted sale unit updates'); + assertEqual(res.data.product.allow_fractional_quantity, true, 'E: fractional quantity flag updates'); + assertEqual(res.data.product.weight_precision, 0, 'E: weight precision updates'); } console.log('\n─── Scenario F: duplicate barcode is rejected on update (excluding self) ───'); @@ -156,6 +170,42 @@ async function main() { assertEqual(lookup.data.products[0].id, created.data.product.id, 'G: correct leading-zero product returned'); } + console.log('\n─── Scenario H: invalid weighted metadata is rejected ───'); + { + const invalidUnit = await api(baseUrl, '/api/products', { + method: 'POST', + body: { category_id: 'cat-137', name: 'Bulk Bad Unit', price: 10, sale_unit: 'stone' }, + headers: authHeader, + }); + assertEqual(invalidUnit.status, 400, 'H: invalid sale_unit rejected'); + + const invalidFlag = await api(baseUrl, `/api/products/${createdId}`, { + method: 'PUT', + body: { allow_fractional_quantity: 'yes' }, + headers: authHeader, + }); + assertEqual(invalidFlag.status, 400, 'H: non-boolean fractional flag rejected'); + + const invalidPrecision = await api(baseUrl, `/api/products/${createdId}`, { + method: 'PUT', + body: { weight_precision: 5 }, + headers: authHeader, + }); + assertEqual(invalidPrecision.status, 400, 'H: out-of-range weight precision rejected'); + + const fractionalEach = await api(baseUrl, '/api/products', { + method: 'POST', + body: { category_id: 'cat-137', name: 'Fractional Each', price: 10, sale_unit: 'each', allow_fractional_quantity: true }, + headers: authHeader, + }); + assertEqual(fractionalEach.status, 400, 'H: each-unit product cannot enable fractional quantities'); + + const weightedToEach = await api(baseUrl, `/api/products/${createdId}`, { + method: 'PUT', body: { sale_unit: 'each' }, headers: authHeader, + }); + assertEqual(weightedToEach.status, 400, 'H: partial update cannot leave an each-unit product fractional'); + } + } finally { server.close(); closeDatabase(); diff --git a/tests/issue-365-scale-barcode.test.ts b/tests/issue-365-scale-barcode.test.ts new file mode 100644 index 00000000..1905ff04 --- /dev/null +++ b/tests/issue-365-scale-barcode.test.ts @@ -0,0 +1,150 @@ +/** + * Unit Test: Issue #365 — scale-generated weighted barcodes + * + * Run: ts-node --transpile-only -P tests/tsconfig.json tests/issue-365-scale-barcode.test.ts + */ + +import assert from 'assert'; +import { parseScaleBarcode, resolveScannedProduct } from '../frontend/src/lib/scale-barcode'; + +const parsed = parseScaleBarcode('2101234012507'); +assert.deepEqual(parsed, { plu: '01234', quantity: 1.25 }, 'default scale barcode parses prefix, PLU, and grams'); + +assert.equal(parseScaleBarcode('9901234012507'), null, 'non-scale prefix is ignored'); +assert.equal(parseScaleBarcode('2101234000007'), null, 'zero-weight scale labels are ignored'); +assert.equal(parseScaleBarcode('2101234ABCDE7'), null, 'non-numeric scale labels are ignored'); + +const products = [ + { + id: 'prod-mango', + category_id: 'cat', + name: 'Mango', + sku: null, + barcode: '01234', + sale_unit: 'kg', + allow_fractional_quantity: true, + weight_precision: 3, + description: null, + price: 120, + cost_price: null, + tax_type: 'none', + tax_rate: 0, + track_inventory: false, + stock_quantity: 0, + low_stock_threshold: null, + is_active: true, + available_online: false, + has_image: false, + updated_at: '', + tags: null, + variants: null, + modifiers: null, + sort_order: 0, + }, + { + id: 'prod-each', + category_id: 'cat', + name: 'Each Item', + sku: null, + barcode: '56789', + sale_unit: 'each', + allow_fractional_quantity: false, + weight_precision: 3, + description: null, + price: 10, + cost_price: null, + tax_type: 'none', + tax_rate: 0, + track_inventory: false, + stock_quantity: 0, + low_stock_threshold: null, + is_active: true, + available_online: false, + has_image: false, + updated_at: '', + tags: null, + variants: null, + modifiers: null, + sort_order: 0, + }, + { + id: 'prod-rice-grams', + category_id: 'cat', + name: 'Rice', + sku: null, + barcode: '77777', + sale_unit: 'g', + allow_fractional_quantity: true, + weight_precision: 0, + description: null, + price: 0.2, + cost_price: null, + tax_type: 'none', + tax_rate: 0, + track_inventory: false, + stock_quantity: 0, + low_stock_threshold: null, + is_active: true, + available_online: false, + has_image: false, + updated_at: '', + tags: null, + variants: null, + modifiers: null, + sort_order: 0, + }, + { + id: 'prod-cheese-lb', + category_id: 'cat', + name: 'Cheese', + sku: null, + barcode: '88888', + sale_unit: 'lb', + allow_fractional_quantity: true, + weight_precision: 3, + description: null, + price: 8, + cost_price: null, + tax_type: 'none', + tax_rate: 0, + track_inventory: false, + stock_quantity: 0, + low_stock_threshold: null, + is_active: true, + available_online: false, + has_image: false, + updated_at: '', + tags: null, + variants: null, + modifiers: null, + sort_order: 0, + }, +] as any[]; + +assert.deepEqual( + resolveScannedProduct('2101234012507', products), + { product: products[0], quantity: 1.25, scaleBarcode: { plu: '01234', quantity: 1.25 } }, + 'scale label resolves weighted product and decimal quantity', +); + +assert.deepEqual( + resolveScannedProduct('56789', products), + { product: products[1], quantity: 1, scaleBarcode: null }, + 'exact barcode keeps ordinary scan behavior', +); + +assert.deepEqual( + resolveScannedProduct('2177777012507', products), + { product: products[2], quantity: 1250, scaleBarcode: { plu: '77777', quantity: 1.25 } }, + 'grams sale units receive gram quantities', +); + +assert.deepEqual( + resolveScannedProduct('2188888012507', products), + { product: products[3], quantity: 2.756, scaleBarcode: { plu: '88888', quantity: 1.25 } }, + 'pound sale units convert from label grams', +); + +assert.equal(resolveScannedProduct('2156789012507', products), null, 'non-fractional products do not resolve scale labels'); + +console.log('✓ Issue #365 scale barcode parser checks passed'); diff --git a/tests/refunds.test.ts b/tests/refunds.test.ts new file mode 100644 index 00000000..400d7211 --- /dev/null +++ b/tests/refunds.test.ts @@ -0,0 +1,294 @@ +/** + * Refund system regression coverage (#278): bill-level and item-level + * refunds, mandatory manager-PIN approval, idempotency, and the + * over-collection guard. + * + * NOTE on PIN rate limiting: checkPinRateLimit (main/routes/orders.ts) keys + * its 5-attempt budget by client IP + action name only (`pin:127.0.0.1:refund` + * for every call in this file, since all requests originate from localhost), + * not by user or bill. Every refund call that reaches the PIN-approval step + * (i.e. passes validation/eligibility/balance checks) consumes one budget + * point, whether the PIN was right or wrong — calls rejected before that step + * (missing bill, over-collection, ineligible item, missing PIN) cost nothing. + * This file is deliberately ordered so exactly 5 calls reach that step before + * the final rate-limit assertion; do not add another successful PIN-reaching + * refund call before it without accounting for the shared budget. + */ +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-refunds-')); +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 jwt = require('jsonwebtoken'); +const { + initTestDb, createApp, startServer, seedOwnerUser, seedManagerUser, seedCategory, seedProduct, + api, assert, assertEqual, getResults, closeDatabase, getDatabase, now, +} = require('./helpers/test-setup'); +const { orderRoutes } = require('../main/routes/orders'); +const { billRoutes } = require('../main/routes/bills'); +const { refundRoutes } = require('../main/routes/refunds'); +const { reportRoutes } = require('../main/routes/reports'); +const { getJWTSecret } = require('../main/routes/auth'); + +async function main() { + console.log('Issue #278: refund system'); + const db = initTestDb(); + const { authHeader: ownerAuth } = seedOwnerUser(db); + const { userId: managerId, authHeader: managerAuth } = seedManagerUser(db); + seedCategory(db, 'cat-refund', 'Refund menu'); + seedProduct(db, 'prod-refund', 'cat-refund', 'Refund item', 100); + seedProduct(db, 'prod-refund-inv', 'cat-refund', 'Refund inventory item', 50, { track_inventory: true, stock_quantity: 20 }); + seedProduct(db, 'prod-refund-weighted', 'cat-refund', 'Weighted refund item', 80, { + sale_unit: 'kg', allow_fractional_quantity: true, weight_precision: 3, + }); + + const forbiddenAuth = { + Authorization: `Bearer ${jwt.sign({ userId: 'chef-refund', email: 'chef@test.local', role: 'chef' }, getJWTSecret(), { expiresIn: '1h' })}`, + }; + + const app = createApp({ + '/api/orders': orderRoutes, + '/api/bills': billRoutes, + '/api/refunds': refundRoutes, + '/api/reports': reportRoutes, + }); + const { baseUrl, server } = await startServer(app); + + async function newPaidBill(productId: string, quantity = 1, headers = ownerAuth) { + const order = await api(baseUrl, '/api/orders', { + method: 'POST', + body: { type: 'takeaway', items: [{ product_id: productId, quantity }] }, + headers, + }); + const bill = await api(baseUrl, '/api/bills/generate', { + method: 'POST', body: { order_id: order.data.order.id }, headers, + }); + const paid = await api(baseUrl, `/api/bills/${bill.data.bill.id}/payment`, { + method: 'POST', body: { method: 'cash', amount: null }, headers, + }); + return { order: order.data.order, bill: paid.data.bill }; + } + + try { + // ── Product quantity policy is backend-authoritative ────────────────── + const wholeUnitFraction = await api(baseUrl, '/api/orders', { + method: 'POST', body: { type: 'takeaway', items: [{ product_id: 'prod-refund', quantity: 1.25 }] }, headers: ownerAuth, + }); + assertEqual(wholeUnitFraction.status, 400, 'order creation rejects fractional quantity for a whole-unit product'); + const weightedOrder = await api(baseUrl, '/api/orders', { + method: 'POST', body: { type: 'takeaway', items: [{ product_id: 'prod-refund-weighted', quantity: 1.25 }] }, headers: ownerAuth, + }); + assertEqual(weightedOrder.status, 201, 'order creation accepts fractional quantity for an enabled weighted product'); + const wholeUnitAppend = await api(baseUrl, `/api/orders/${weightedOrder.data.order.id}/items`, { + method: 'POST', body: { items: [{ product_id: 'prod-refund', quantity: 0.5 }] }, headers: ownerAuth, + }); + assertEqual(wholeUnitAppend.status, 400, 'order append rejects fractional quantity for a whole-unit product'); + db.prepare("UPDATE products SET allow_fractional_quantity = 1 WHERE id = 'prod-refund'").run(); + const inconsistentWholeUnit = await api(baseUrl, '/api/orders', { + method: 'POST', body: { type: 'takeaway', items: [{ product_id: 'prod-refund', quantity: 0.5 }] }, headers: ownerAuth, + }); + assertEqual(inconsistentWholeUnit.status, 400, 'order creation rejects fractional each-unit quantity even with inconsistent catalog metadata'); + db.prepare("UPDATE products SET allow_fractional_quantity = 0 WHERE id = 'prod-refund'").run(); + + // ── Role gating ────────────────────────────────────────────────────── + const { bill: gatedBill } = await newPaidBill('prod-refund'); + const gated = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: gatedBill.id, amount: 100, method: 'cash', override_pin: '1234' }, headers: forbiddenAuth, + }); + assertEqual(gated.status, 403, 'a chef (non owner/manager/cashier) cannot create a refund'); + + // ── PIN approval: missing (no budget cost) ───────────────────────────── + const noPinBill = await newPaidBill('prod-refund'); + const noPin = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: noPinBill.bill.id, amount: 100, method: 'cash' }, headers: ownerAuth, + }); + assertEqual(noPin.status, 400, 'a refund without override_pin is rejected'); + + // ── PIN approval: wrong (budget point 1) ──────────────────────────────── + const wrongPinBill = await newPaidBill('prod-refund'); + const wrongPin = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: wrongPinBill.bill.id, amount: 100, method: 'cash', override_pin: '0000', manager_id: managerId }, headers: ownerAuth, + }); + assertEqual(wrongPin.status, 403, 'a refund with the wrong manager PIN is rejected'); + + // ── PIN approval: correct, + idempotency replay/mismatch (budget point 2) ── + const idemBill = await newPaidBill('prod-refund'); + const idemHeaders = { ...ownerAuth, 'Idempotency-Key': 'refund-278-idem-1' }; + const idemBody = { bill_id: idemBill.bill.id, amount: 100, method: 'cash', reason: 'Customer complaint', override_pin: '1234', manager_id: managerId }; + const created = await api(baseUrl, '/api/refunds', { method: 'POST', body: idemBody, headers: idemHeaders }); + assertEqual(created.status, 201, 'a refund with a valid manager PIN is accepted'); + assertEqual(created.data.refund.approved_by, managerId, 'the approving manager id is persisted'); + assertEqual(created.data.bill.payment_status, 'refunded', 'a full-amount refund marks the bill refunded'); + + const replay = await api(baseUrl, '/api/refunds', { method: 'POST', body: idemBody, headers: idemHeaders }); + assertEqual(replay.status, 201, 'replaying the same Idempotency-Key + body returns the stored response'); + assertEqual(replay.data.refund.id, created.data.refund.id, 'the replay does not create a second refund row'); + const refundCountAfterReplay = db.prepare('SELECT COUNT(*) AS n FROM refunds WHERE bill_id = ?').get(idemBill.bill.id) as any; + assertEqual(refundCountAfterReplay.n, 1, 'the replay does not insert a duplicate refunds row'); + + const mismatch = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { ...idemBody, amount: 50 }, headers: idemHeaders, + }); + assertEqual(mismatch.status, 409, 'reusing an Idempotency-Key with a different body is rejected'); + + // ── Over-collection guard (full refund succeeds = budget point 3) ───── + const overBill = await newPaidBill('prod-refund'); + const overshoot = await api(baseUrl, '/api/refunds', { + method: 'POST', + body: { bill_id: overBill.bill.id, amount: (Number(overBill.bill.paid_amount) + 0.01).toFixed(2), method: 'cash', override_pin: '1234', manager_id: managerId }, + headers: ownerAuth, + }); + assertEqual(overshoot.status, 400, 'a refund exceeding the paid amount is rejected'); + const exact = await api(baseUrl, '/api/refunds', { + method: 'POST', + body: { bill_id: overBill.bill.id, amount: overBill.bill.paid_amount, method: 'cash', override_pin: '1234', manager_id: managerId }, + headers: ownerAuth, + }); + assertEqual(exact.status, 201, 'a refund exactly equal to the paid amount is accepted'); + assertEqual(exact.data.bill.payment_status, 'refunded', 'refunding exactly the paid amount marks the bill refunded'); + const noBalanceLeft = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: overBill.bill.id, amount: 1, method: 'cash', override_pin: '1234', manager_id: managerId }, headers: ownerAuth, + }); + assertEqual(noBalanceLeft.status, 400, 'a further refund on a fully refunded bill is rejected before touching the PIN budget'); + + // ── Two-hour eligibility window (rejected before PIN budget) ───────── + const expiredBill = await newPaidBill('prod-refund'); + db.prepare("UPDATE orders SET created_at = datetime('now', '-121 minutes') WHERE id = ?").run(expiredBill.order.id); + const expiredRefund = await api(baseUrl, '/api/refunds', { + method: 'POST', + body: { bill_id: expiredBill.bill.id, amount: expiredBill.bill.paid_amount, method: 'cash', override_pin: '1234', manager_id: managerId }, + headers: ownerAuth, + }); + assertEqual(expiredRefund.status, 409, 'a refund more than two hours after order creation is rejected'); + assertEqual( + (db.prepare('SELECT COUNT(*) AS count FROM refunds WHERE bill_id = ?').get(expiredBill.bill.id) as any).count, + 0, + 'an expired refund does not create a refund row', + ); + + // ── Partial refund (budget point 4) ──────────────────────────────────── + const partialBill = await newPaidBill('prod-refund'); + const salesBeforePartialRefund = await api(baseUrl, '/api/reports/daily-stats', { headers: ownerAuth }); + const partialAmount = (Number(partialBill.bill.paid_amount) * 0.4).toFixed(2); + const partial = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: partialBill.bill.id, amount: partialAmount, method: 'cash', override_pin: '1234', manager_id: managerId }, headers: ownerAuth, + }); + assertEqual(partial.status, 201, 'a partial refund is accepted'); + assertEqual(partial.data.bill.payment_status, 'partially_refunded', 'a partial refund marks the bill partially_refunded'); + const salesAfterPartialRefund = await api(baseUrl, '/api/reports/daily-stats', { headers: ownerAuth }); + assertEqual( + Number((salesBeforePartialRefund.data.sales - salesAfterPartialRefund.data.sales).toFixed(2)), + Number(partialAmount), + 'paid-sales reporting subtracts a partial refund without dropping the whole bill', + ); + const cashAfterPartialRefund = salesAfterPartialRefund.data.paymentMethods.find((row: any) => row.method === 'cash'); + assertEqual(cashAfterPartialRefund.total, salesAfterPartialRefund.data.sales, 'cash payment reporting includes refund lines as negative cash movement'); + + const paymentDate = '2025-01-10T12:00:00.000Z'; + const refundDate = '2025-01-11T12:00:00.000Z'; + const paymentDetails = JSON.parse((db.prepare('SELECT payment_details FROM bills WHERE id = ?').get(partialBill.bill.id) as any).payment_details); + paymentDetails.forEach((line: any) => { line.timestamp = paymentDate; }); + db.prepare('UPDATE bills SET created_at = ?, paid_at = ?, payment_details = ? WHERE id = ?') + .run(paymentDate, paymentDate, JSON.stringify(paymentDetails), partialBill.bill.id); + db.prepare('UPDATE refunds SET created_at = ? WHERE bill_id = ?').run(refundDate, partialBill.bill.id); + const paymentDay = await api(baseUrl, '/api/reports/summary?date=2025-01-10', { headers: ownerAuth }); + const lateRefundDay = await api(baseUrl, '/api/reports/summary?date=2025-01-11', { headers: ownerAuth }); + assertEqual(paymentDay.data.summary.bills.collected, partialBill.bill.paid_amount, 'payment-day revenue keeps the original payment when a refund is posted later'); + assertEqual(paymentDay.data.summary.paymentMethods[0].total, partialBill.bill.paid_amount, 'payment-day method total matches payment-day revenue'); + assertEqual(lateRefundDay.data.summary.bills.collected, -Number(partialAmount), 'late refund reduces revenue on the refund day'); + assertEqual(lateRefundDay.data.summary.paymentMethods[0].total, -Number(partialAmount), 'refund-day method total matches refund-day revenue'); + const overRemainder = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: partialBill.bill.id, amount: partialBill.bill.paid_amount, method: 'cash', override_pin: '1234', manager_id: managerId }, headers: ownerAuth, + }); + assertEqual(overRemainder.status, 400, 'a refund exceeding the remaining refundable balance is rejected'); + + // ── GET /api/refunds listing (no PIN, no budget cost) ────────────────── + const list = await api(baseUrl, `/api/refunds?bill_id=${partialBill.bill.id}`, { headers: ownerAuth }); + assertEqual(list.status, 200, 'listing refunds for a bill succeeds'); + assertEqual(list.data.refunds.length, 1, 'the listing is filtered to the requested bill'); + assertEqual(list.data.refunds[0].bill_id, partialBill.bill.id, 'the listed refund belongs to the requested bill'); + + // ── Bill not found (no budget cost) ───────────────────────────────────── + const notFound = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: 999999999, amount: 10, method: 'cash', override_pin: '1234', manager_id: managerId }, headers: ownerAuth, + }); + assertEqual(notFound.status, 404, 'a refund against a non-existent bill returns 404'); + + // ── Split allocation ownership is checked before approval ───────────── + db.prepare("UPDATE settings SET value = 'true' WHERE key = 'split_checks_enabled'").run(); + const splitOrder = await api(baseUrl, '/api/orders', { + method: 'POST', body: { type: 'dine_in', items: [{ product_id: 'prod-refund', quantity: 2 }] }, headers: ownerAuth, + }); + const splitItem = splitOrder.data.order.items[0]; + db.prepare("UPDATE order_items SET status = 'ready' WHERE id = ?").run(splitItem.id); + const splitSource = await api(baseUrl, '/api/bills/generate', { + method: 'POST', body: { order_id: splitOrder.data.order.id }, headers: ownerAuth, + }); + const splitChecks = await api(baseUrl, `/api/bills/${splitSource.data.bill.id}/split-check`, { + method: 'POST', + body: { checks: [ + { label: 'Guest 1', items: [{ order_item_id: splitItem.id, quantity: 1 }] }, + { label: 'Guest 2', items: [{ order_item_id: splitItem.id, quantity: 1 }] }, + ] }, + headers: ownerAuth, + }); + assertEqual(splitChecks.status, 201, 'split-refund fixture creates two guest checks'); + const partialAllocationRefund = await api(baseUrl, '/api/refunds', { + method: 'POST', + body: { bill_id: splitChecks.data.bills[0].id, order_item_id: splitItem.id, method: 'cash', override_pin: '1234', manager_id: managerId }, + headers: ownerAuth, + }); + assertEqual(partialAllocationRefund.status, 409, 'a split check cannot refund an order item it owns only partially'); + assertEqual((db.prepare('SELECT status FROM order_items WHERE id = ?').get(splitItem.id) as any).status, 'ready', 'rejected split refund preserves the shared item'); + + // ── Item-level refund on a paid bill (budget point 5) ────────────────── + const invItem = await newPaidBill('prod-refund-inv', 2); + const stockBeforeReady = (db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-refund-inv') as any).stock_quantity; + assertEqual(stockBeforeReady, 18, 'stock is deducted when the order is created'); + const itemRow = db.prepare('SELECT * FROM order_items WHERE order_id = ?').get(invItem.order.id) as any; + db.prepare("UPDATE order_items SET status = 'ready' WHERE id = ?").run(itemRow.id); + const itemRefund = await api(baseUrl, '/api/refunds', { + method: 'POST', + body: { bill_id: invItem.bill.id, order_item_id: itemRow.id, method: 'cash', override_pin: '1234', manager_id: managerId }, + headers: ownerAuth, + }); + assertEqual(itemRefund.status, 201, 'an item-level refund on a ready item is accepted'); + assertEqual(itemRefund.data.refund.order_item_id, itemRow.id, 'the refund records the order_item_id'); + assertEqual(itemRefund.data.refund.amount_cents, Math.round(Number(itemRow.total) * 100), "the refund amount matches the item's total"); + const stockAfterRefund = (db.prepare('SELECT stock_quantity FROM products WHERE id = ?').get('prod-refund-inv') as any).stock_quantity; + assertEqual(stockAfterRefund, 18, 'inventory is NOT restored by an item-level refund'); + const refundedItem = db.prepare('SELECT * FROM order_items WHERE id = ?').get(itemRow.id) as any; + assertEqual(refundedItem.status, 'refunded', 'the refunded item transitions to the refunded status'); + const mirroredRow = db.prepare("SELECT * FROM order_items WHERE order_id = ? AND status = 'void_adjustment'").get(invItem.order.id) as any; + assert(!!mirroredRow, 'a mirrored void_adjustment row is inserted for the refunded item'); + assertEqual(mirroredRow.total, -itemRow.total, 'the mirrored row negates the original total'); + + const doubleRefund = await api(baseUrl, '/api/refunds', { + method: 'POST', body: { bill_id: invItem.bill.id, order_item_id: itemRow.id, method: 'cash', override_pin: '1234', manager_id: managerId }, headers: ownerAuth, + }); + assertEqual(doubleRefund.status, 409, 'refunding an already-refunded item is rejected before touching the PIN budget'); + + // ── Rate limiting: the shared PIN budget above is now exhausted ──────── + const sixthAttempt = await api(baseUrl, '/api/refunds', { + method: 'POST', + body: { bill_id: wrongPinBill.bill.id, amount: 100, method: 'cash', override_pin: '1234', manager_id: managerId }, + headers: ownerAuth, + }); + assertEqual(sixthAttempt.status, 429, 'the 6th refund call reaching PIN approval is throttled regardless of a correct PIN'); + } finally { + server.close(); + closeDatabase(); + try { fs.rmSync(testDir, { recursive: true }); } catch {} + } + const { passed, failed, total } = getResults(); + console.log(`${passed}/${total} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((error: any) => { console.error(error); process.exit(1); });