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" />
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); });