diff --git a/docs/roles-and-permissions.md b/docs/roles-and-permissions.md index 20761e8eb..d744e1aed 100644 --- a/docs/roles-and-permissions.md +++ b/docs/roles-and-permissions.md @@ -56,6 +56,10 @@ A check means the role is allowed to use the capability. A dash means it is not | System | Use database tools and backups | ✓ | — | — | — | — | | Orders | Use the standalone Server App | ✓ | ✓ | — | ✓ | — | | Support | Contact support and view diagnostics | ✓ | ✓ | ✓ | ✓ | ✓ | +| Expenses | Add or delete expense categories | ✓ | ✓ | — | — | — | +| Expenses | Record expenses and due payments | ✓ | ✓ | ✓ | ✓ | ✓ | +| Expenses | Void mistyped entries, payments, and floats | ✓ | ✓ | — | — | — | +| Expenses | Log opening floats and cash counts | ✓ | ✓ | ✓ | ✓ | ✓ | ## Important scope notes @@ -65,6 +69,8 @@ A check means the role is allowed to use the capability. A dash means it is not - **Server App:** The standalone Server App is intentionally restricted to `server`, `manager`, and `owner` roles. It is separate from the dashboard navigation. - **Staff management:** Managers can manage operational staff, but cannot modify or deactivate owner/manager accounts. Only owners can change roles for an existing account, and the last active owner cannot be demoted. - **Conditional surfaces:** Business type, feature settings (such as KDS or WhatsApp), and account state can hide or disable a surface without changing the fixed role boundary. +- **Expense records are append-only:** Any staff member can record an expense or a due payment against an existing category, but no role — including owner — can edit an individual expense/payment entry once recorded; it is a permanent audit trail. Owners and managers can void a mistyped entry, payment, or opening float, which drops it from every due and total while keeping the row itself. Only the expense *category* itself can be deleted (owner/manager only, and only once its due balance is settled). +- **Cash Counter's expected-cash figure cannot be overridden:** the daily/monthly expected cash total is always calculated (opening float + cash collected from orders − cash refunds − cash paid out as expenses, same drawer rule as the Z day-close); no role can edit or replace it. Staff can log an opening float (once per day) and any number of physical cash counts, both append-only, purely as reference facts compared against the calculated figure — never a substitute for it. ## Research-backed presentation choice diff --git a/frontend/src/app/(dashboard)/cash-counter/page.tsx b/frontend/src/app/(dashboard)/cash-counter/page.tsx new file mode 100644 index 000000000..2738f9435 --- /dev/null +++ b/frontend/src/app/(dashboard)/cash-counter/page.tsx @@ -0,0 +1,378 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import api from '@/lib/api'; +import { currentUtcMonth, todayInTimezone, todayUtcDate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import toast from 'react-hot-toast'; +import { X, Wallet, ClipboardCheck } from 'lucide-react'; +import type { CashDailySummary, CashMonthlySummary } from '@/lib/types'; +import { useTranslations } from 'use-intl'; +import { useAuthStore } from '@/store/auth'; +import { useConfirm } from '@/hooks/use-confirm'; +import { ROLE_ACCESS, hasRole } from '@shared/role-permissions'; + +export default function CashCounterPage() { + const t = useTranslations('cashCounter'); + const tCommon = useTranslations('common'); + const { currentTenant } = useAuthStore(); + const { confirm, ConfirmDialog } = useConfirm(); + const isAdmin = hasRole(currentTenant?.role, ROLE_ACCESS.ownerManager); + + const [date, setDate] = useState(todayUtcDate()); + const [daily, setDaily] = useState(null); + const [loadingDaily, setLoadingDaily] = useState(true); + + const [month, setMonth] = useState(currentUtcMonth()); + const [monthly, setMonthly] = useState(null); + + const [showFloatForm, setShowFloatForm] = useState(false); + const [floatAmount, setFloatAmount] = useState(''); + const [floatNote, setFloatNote] = useState(''); + + const [showCountForm, setShowCountForm] = useState(false); + const [countAmount, setCountAmount] = useState(''); + const [countNote, setCountNote] = useState(''); + + // Store-local day for date defaults and picker limits (see /expenses). + const [storeTimezone, setStoreTimezone] = useState(null); + const today = storeTimezone ? todayInTimezone(storeTimezone) : todayUtcDate(); + useEffect(() => { + api.get('/settings/business') + .then(({ data }) => { + const tz = typeof data?.timezone === 'string' && data.timezone ? data.timezone : null; + if (!tz) return; + setStoreTimezone(tz); + const storeToday = todayInTimezone(tz); + if (storeToday !== todayUtcDate()) { + setDate(storeToday); + setMonth(storeToday.slice(0, 7)); + } + }) + .catch(() => {}); + }, []); + + // Only the latest request may write state: a superseded date/month + // response must not overwrite the current selection. + const dailySeq = useRef(0); + const monthlySeq = useRef(0); + + const loadDaily = () => { + const seq = ++dailySeq.current; + return api.get('/cash-counter/daily', { params: { date } }) + .then(({ data }) => { if (seq === dailySeq.current) setDaily(data); }) + .catch(() => { if (seq === dailySeq.current) toast.error(t('failedToLoad')); }) + .finally(() => { if (seq === dailySeq.current) setLoadingDaily(false); }); + }; + + const loadMonthly = (m: string) => { + const seq = ++monthlySeq.current; + return api.get('/cash-counter/monthly', { params: { month: m } }) + .then(({ data }) => { if (seq === monthlySeq.current) setMonthly(data); }) + .catch(() => { if (seq === monthlySeq.current) toast.error(t('failedToLoadMonthly')); }); + }; + + useEffect(() => { + loadDaily(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [date]); + + useEffect(() => { + loadMonthly(month); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [month]); + + const openFloatForm = () => { + setFloatAmount(''); + setFloatNote(''); + setShowFloatForm(true); + }; + + const handleVoidFloat = async () => { + if (!daily?.opening_float) return; + if (!await confirm(tCommon('confirmVoid'), { destructive: true })) return; + try { + await api.post(`/cash-counter/opening-float/${daily.opening_float.id}/void`); + toast.success(tCommon('voided')); + loadDaily(); + loadMonthly(month); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToSave')); + } + }; + + const handleAddFloat = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await api.post('/cash-counter/opening-float', { date, amount: Number(floatAmount), note: floatNote || undefined }); + toast.success(t('openingFloatSet')); + setShowFloatForm(false); + loadDaily(); + loadMonthly(month); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToSave')); + } + }; + + const openCountForm = () => { + setCountAmount(''); + setCountNote(''); + setShowCountForm(true); + }; + + const handleAddCount = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await api.post('/cash-counter/count', { date, counted_amount: Number(countAmount), note: countNote || undefined }); + toast.success(t('countRecorded')); + setShowCountForm(false); + loadDaily(); + loadMonthly(month); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToSave')); + } + }; + + const varianceLabel = (variance: number | null) => { + if (variance === null) return null; + if (variance === 0) return t('varianceMatch'); + return variance > 0 ? t('varianceOverage', { amount: variance.toFixed(2) }) : t('varianceShortage', { amount: Math.abs(variance).toFixed(2) }); + }; + + const varianceColor = (variance: number | null) => { + if (variance === null) return 'text-gray-500'; + if (variance === 0) return 'text-emerald-600'; + return variance > 0 ? 'text-blue-600' : 'text-red-600'; + }; + + return ( +
+ {ConfirmDialog} +
+

{t('title')}

+ setDate(e.target.value)} + aria-label={t('selectDate')} + className="px-3 py-1.5 border rounded-lg text-sm outline-none focus:ring-2 focus:ring-brand" + /> +
+ + {loadingDaily ? ( +

{tCommon('loading')}

+ ) : !daily ? ( +

{t('failedToLoad')}

+ ) : ( + <> +
+
+

{t('openingFloat')}

+

{(daily.opening_float?.amount ?? 0).toFixed(2)}

+
+
+

{t('cashFromOrders')}

+

+{daily.cash_from_orders.total.toFixed(2)}

+ {daily.cash_refunds.total > 0 && ( +

-{daily.cash_refunds.total.toFixed(2)} {t('cashRefunds')}

+ )} +
+
+

{t('cashExpenses')}

+

-{daily.cash_expenses.total.toFixed(2)}

+
+
+

{t('expectedCash')}

+

{daily.expected_cash.toFixed(2)}

+
+
+ +
+ + + {isAdmin && daily.opening_float && ( + + )} +
+ +
+
+

{t('countLog')}

+ {daily.counts.length === 0 ? ( +

{t('noCounts')}

+ ) : ( +
+ {daily.counts.map((count) => { + const variance = Math.round((count.counted_amount - daily.expected_cash) * 100) / 100; + return ( +
+
+

{count.counted_amount.toFixed(2)}

+

+ {count.created_by_name ? t('recordedBy', { name: count.created_by_name }) : ''} + {count.note ? ` · ${count.note}` : ''} +

+
+

+ {varianceLabel(variance)} +

+
+ ); + })} +
+ )} +
+ +
+

{t('cashFromOrders')}

+ {daily.cash_from_orders.payments.length === 0 ? ( +

{t('noPayments')}

+ ) : ( +
+ {daily.cash_from_orders.payments.map((payment) => ( +
+

{payment.bill_number}

+

+{payment.amount.toFixed(2)}

+
+ ))} +
+ )} +
+ +
+

{t('cashExpenses')}

+ {daily.cash_expenses.payments.length === 0 ? ( +

{t('noPayments')}

+ ) : ( +
+ {daily.cash_expenses.payments.map((payment) => ( +
+
+

{payment.category_name}

+

+ {payment.created_by_name ? t('recordedBy', { name: payment.created_by_name }) : ''} + {payment.note ? ` · ${payment.note}` : ''} +

+
+

-{payment.amount.toFixed(2)}

+
+ ))} +
+ )} +
+
+ + )} + +
+
+

{t('monthlyReport')}

+ setMonth(e.target.value)} + aria-label={t('selectMonth')} + className="px-3 py-1.5 border rounded-lg text-sm outline-none focus:ring-2 focus:ring-brand" + /> +
+ {monthly && ( +
+ + + + + + + + + + + + + + + {monthly.days.map((day) => ( + + + + + + + + + + + ))} + + + + + + + + + + + +
{t('date')}{t('openingFloat')}{t('cashFromOrders')}{t('cashRefunds')}{t('cashExpenses')}{t('expectedCash')}{t('counted')}{t('variance')}
{day.date}{day.opening_float.toFixed(2)}{day.cash_from_orders.toFixed(2)}{day.cash_refunds > 0 ? `-${day.cash_refunds.toFixed(2)}` : '—'}{day.cash_expenses.toFixed(2)}{day.expected_cash.toFixed(2)}{day.latest_count !== null ? day.latest_count.toFixed(2) : '—'}{day.variance !== null ? day.variance.toFixed(2) : '—'}
{t('overall')}{monthly.totals.total_opening_floats.toFixed(2)}{monthly.totals.total_cash_from_orders.toFixed(2)}{monthly.totals.total_cash_refunds.toFixed(2)}{monthly.totals.total_cash_expenses.toFixed(2)}{t('netCash')}: {monthly.totals.net.toFixed(2)}
+
+ )} +
+ + {showFloatForm && ( +
+
+
+

{t('setOpeningFloat')}

+ +
+
+ setFloatAmount(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" required + /> + setFloatNote(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> + +
+
+
+ )} + + {showCountForm && ( +
+
+
+

{t('recordCount')}

+ +
+
+ setCountAmount(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" required + /> + setCountNote(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx new file mode 100644 index 000000000..51eaac13a --- /dev/null +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -0,0 +1,426 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import api from '@/lib/api'; +import { currentUtcMonth, todayInTimezone, todayUtcDate } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import toast from 'react-hot-toast'; +import { Plus, X, Trash2, Wallet, Receipt } from 'lucide-react'; +import type { ExpenseCategory, ExpenseLedgerEntry, ExpenseMonthSummary, ExpensePaymentMethod } from '@/lib/types'; +import { useTranslations } from 'use-intl'; +import { useAuthStore } from '@/store/auth'; +import { useConfirm } from '@/hooks/use-confirm'; +import { ROLE_ACCESS, hasRole } from '@shared/role-permissions'; + +type LedgerRow = ExpenseLedgerEntry & { kind: 'expense' | 'payment' }; + +const PAYMENT_METHODS: ExpensePaymentMethod[] = ['cash', 'card', 'upi']; + +type PaymentMethodLabelKey = 'paymentMethodCash' | 'paymentMethodCard' | 'paymentMethodUpi'; + +function paymentMethodLabelKey(method: ExpensePaymentMethod): PaymentMethodLabelKey { + return `paymentMethod${method.charAt(0).toUpperCase()}${method.slice(1)}` as PaymentMethodLabelKey; +} + +export default function ExpensesPage() { + const t = useTranslations('expenses'); + const tCommon = useTranslations('common'); + const { currentTenant } = useAuthStore(); + const { confirm, ConfirmDialog } = useConfirm(); + const isAdmin = hasRole(currentTenant?.role, ROLE_ACCESS.ownerManager); + + const [categories, setCategories] = useState([]); + const [recent, setRecent] = useState([]); + const [loading, setLoading] = useState(true); + const [filterDate, setFilterDate] = useState(''); + + const [showCategoryForm, setShowCategoryForm] = useState(false); + const [categoryName, setCategoryName] = useState(''); + + const [activeCategory, setActiveCategory] = useState(null); + const [modalMode, setModalMode] = useState<'expense' | 'payment' | null>(null); + const [amount, setAmount] = useState(''); + const [note, setNote] = useState(''); + const [date, setDate] = useState(todayUtcDate()); + const [method, setMethod] = useState('cash'); + const [customMethods, setCustomMethods] = useState<{ id: number; name: string }[]>([]); + + const [summaryMonth, setSummaryMonth] = useState(currentUtcMonth()); + const [summary, setSummary] = useState(null); + + // Store-local day for date defaults and picker limits. Falls back to UTC + // until the business settings load; only corrects state when it differs. + const [storeTimezone, setStoreTimezone] = useState(null); + const today = storeTimezone ? todayInTimezone(storeTimezone) : todayUtcDate(); + useEffect(() => { + api.get('/settings/business') + .then(({ data }) => { + const tz = typeof data?.timezone === 'string' && data.timezone ? data.timezone : null; + if (!tz) return; + setStoreTimezone(tz); + const storeToday = todayInTimezone(tz); + if (storeToday !== todayUtcDate()) { + setDate(storeToday); + setSummaryMonth(storeToday.slice(0, 7)); + } + }) + .catch(() => {}); + }, []); + + // Only the latest request may write state: a superseded date/month + // response must not overwrite the current selection. + const loadSeq = useRef(0); + const summarySeq = useRef(0); + + const load = () => { + const seq = ++loadSeq.current; + const ledgerParams = { limit: 20, ...(filterDate ? { date: filterDate } : {}) }; + return Promise.all([ + api.get('/expenses/categories'), + api.get('/expenses/entries', { params: ledgerParams }), + api.get('/expenses/payments', { params: ledgerParams }), + api.get('/payment-methods'), + ]) + .then(([categoriesRes, entriesRes, paymentsRes, methodsRes]) => { + if (seq !== loadSeq.current) return; + setCustomMethods(methodsRes.data.payment_methods || []); + setCategories(categoriesRes.data.categories || []); + const merged: LedgerRow[] = [ + ...(entriesRes.data.entries || []).map((row: ExpenseLedgerEntry) => ({ ...row, kind: 'expense' as const })), + ...(paymentsRes.data.payments || []).map((row: ExpenseLedgerEntry) => ({ ...row, kind: 'payment' as const })), + ].sort((a, b) => (a.date === b.date ? (a.created_at < b.created_at ? 1 : -1) : (a.date < b.date ? 1 : -1))); + setRecent(merged.slice(0, 20)); + }) + .catch(() => { if (seq === loadSeq.current) toast.error(t('failedToLoad')); }) + .finally(() => { if (seq === loadSeq.current) setLoading(false); }); + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterDate]); + + const loadSummary = (month: string) => { + const seq = ++summarySeq.current; + return api.get('/expenses/summary', { params: { month } }) + .then(({ data }) => { if (seq === summarySeq.current) setSummary(data); }) + .catch(() => { if (seq === summarySeq.current) toast.error(t('failedToLoadSummary')); }); + }; + + useEffect(() => { + loadSummary(summaryMonth); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [summaryMonth]); + + const openCategoryForm = () => { + setCategoryName(''); + setShowCategoryForm(true); + }; + + const handleAddCategory = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await api.post('/expenses/categories', { name: categoryName }); + toast.success(t('categoryCreated')); + setShowCategoryForm(false); + load(); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToSave')); + } + }; + + const handleDeleteCategory = async (category: ExpenseCategory) => { + if (category.due !== 0) return; + if (!await confirm(t('confirmDeleteCategory', { name: category.name }), { destructive: true })) return; + try { + await api.delete(`/expenses/categories/${category.id}`); + toast.success(t('categoryDeleted')); + load(); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToDelete')); + } + }; + + const handleVoidRow = async (row: LedgerRow) => { + if (!await confirm(tCommon('confirmVoid'), { destructive: true })) return; + try { + const path = row.kind === 'expense' + ? `/expenses/entries/${row.id}/void` + : `/expenses/payments/${row.id}/void`; + await api.post(path); + toast.success(tCommon('voided')); + load(); + loadSummary(summaryMonth); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToSave')); + } + }; + + const openModal = (category: ExpenseCategory, mode: 'expense' | 'payment') => { + setActiveCategory(category); + setModalMode(mode); + setAmount(''); + setNote(''); + setDate(today); + setMethod('cash'); + }; + + const closeModal = () => { + setActiveCategory(null); + setModalMode(null); + }; + + const handleSubmitLedger = async (e: React.FormEvent) => { + e.preventDefault(); + if (!activeCategory || !modalMode) return; + try { + const path = modalMode === 'expense' ? '/expenses/entries' : '/expenses/payments'; + const body: Record = { category_id: activeCategory.id, amount: Number(amount), note: note || undefined, date }; + if (modalMode === 'payment') body.method = method; + await api.post(path, body); + toast.success(modalMode === 'expense' ? t('entryAdded') : t('paymentRecorded')); + closeModal(); + load(); + loadSummary(summaryMonth); + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } } }; + toast.error(err.response?.data?.error || tCommon('failedToSave')); + } + }; + + if (loading) return

{tCommon('loading')}

; + + const customMethodNames = summary + ? Array.from(new Set(summary.categories.flatMap((c) => Object.keys(c.custom_payments)))) + : []; + + return ( +
+ {ConfirmDialog} +
+

{t('title')}

+ {isAdmin && ( + + )} +
+ +
+ {categories.map((category) => ( +
+
+

{category.name}

+ {isAdmin && ( + + )} +
+

0 ? 'text-red-600' : category.due < 0 ? 'text-emerald-600' : 'text-gray-500'}`}> + {t('due')}: {category.due.toFixed(2)} +

+
+ + +
+
+ ))} +
+ + {categories.length === 0 &&

{t('noCategories')}

} + +
+
+

{t('monthlyReport')}

+ setSummaryMonth(e.target.value)} + aria-label={t('selectMonth')} + className="px-3 py-1.5 border rounded-lg text-sm outline-none focus:ring-2 focus:ring-brand" + /> +
+ {summary && ( +
+ + + + + + + + + + {customMethodNames.map((m) => ( + + ))} + + + + {summary.categories.map((row) => ( + + + + + + + + {customMethodNames.map((m) => ( + + ))} + + ))} + + + + + + + + + + {customMethodNames.map((m) => ( + + ))} + + +
{t('category')}{t('totalExpenses')}{t('totalPaid')}{t('paymentMethodCash')}{t('paymentMethodCard')}{t('paymentMethodUpi')}{m}
{row.category_name}{row.total_expenses.toFixed(2)}{row.total_payments.toFixed(2)}{row.payments_by_method.cash.toFixed(2)}{row.payments_by_method.card.toFixed(2)}{row.payments_by_method.upi.toFixed(2)}{(row.custom_payments[m] ?? 0).toFixed(2)}
{t('overall')}{summary.overall.total_expenses.toFixed(2)}{summary.overall.total_payments.toFixed(2)}{summary.overall.payments_by_method.cash.toFixed(2)}{summary.overall.payments_by_method.card.toFixed(2)}{summary.overall.payments_by_method.upi.toFixed(2)}{(summary.overall.custom_payments[m] ?? 0).toFixed(2)}
+ {summary.categories.length === 0 &&

{t('noCategories')}

} +
+ )} +
+ +
+
+

{t('history')}

+
+ setFilterDate(e.target.value)} + aria-label={t('filterByDate')} + className="px-3 py-1.5 border rounded-lg text-sm outline-none focus:ring-2 focus:ring-brand" + /> + {filterDate && ( + + )} +
+
+ {recent.length === 0 ? ( +

{t('noEntries')}

+ ) : ( +
+ {recent.map((row) => ( +
+
+

{row.category_name}

+

+ {row.date} + {' · '} + {row.kind === 'expense' ? t('entryTypeExpense') : t('entryTypePayment')} + {row.kind === 'payment' && row.method ? ` · ${row.method === 'cash' || row.method === 'card' || row.method === 'upi' ? t(paymentMethodLabelKey(row.method)) : row.method}` : ''} + {row.created_by_name ? ` · ${t('recordedBy', { name: row.created_by_name })}` : ''} + {row.note ? ` · ${row.note}` : ''} +

+
+
+

+ {row.kind === 'expense' ? '+' : '-'}{Number(row.amount).toFixed(2)} +

+ {isAdmin && ( + + )} +
+
+ ))} +
+ )} +
+ + {showCategoryForm && ( +
+
+
+

{t('addCategory')}

+ +
+
+ setCategoryName(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" required + /> + +
+
+
+ )} + + {activeCategory && modalMode && ( +
+
+
+

{modalMode === 'expense' ? t('addExpense') : t('recordPayment')} — {activeCategory.name}

+ +
+
+ setAmount(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" required + /> +
+ + setDate(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" required + /> +
+ {modalMode === 'payment' && ( +
+ + +
+ )} + setNote(e.target.value)} + className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" + /> + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9f629cb8f..d2c626f9d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -21,6 +21,8 @@ import { Sun, Moon, Monitor, + Receipt, + Banknote, type LucideIcon, } from 'lucide-react'; import { useTranslations, type AppConfig } from 'use-intl'; @@ -75,6 +77,8 @@ const ALL_NAV_ITEMS: NavItem[] = [ { href: '/settings?tab=kds', labelKey: 'kds', icon: ChefHat, roles: ROLE_ACCESS.ownerManager, businessTypes: ['restaurant'] }, { href: '/customers', labelKey: 'customers', icon: Users, roles: ROLE_ACCESS.ownerManager, businessTypes: null }, { href: '/staff', labelKey: 'staff', icon: UserCog, roles: ROLE_ACCESS.ownerManager, businessTypes: null }, + { href: '/expenses', labelKey: 'expenses', icon: Receipt, roles: ROLE_ACCESS.allStaff, businessTypes: null }, + { href: '/cash-counter', labelKey: 'cashCounter', icon: Banknote, roles: ROLE_ACCESS.allStaff, businessTypes: null }, { href: '/settings', labelKey: 'settings', icon: Settings, roles: ROLE_ACCESS.ownerManager, businessTypes: null }, ]; diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 4fc99e2e6..92cdb8b3e 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -86,6 +86,37 @@ "businessType": { "restaurant": "Restaurant" }, + "cashCounter": { + "cashExpenses": "Barausgaben", + "cashFromOrders": "Bareinnahmen aus Bestellungen", + "cashRefunds": "Rückerstattungen (bar)", + "countLog": "Zählprotokoll", + "countRecorded": "Kassenzählung gespeichert", + "counted": "Gezählt", + "countedAmount": "Gezählter Betrag", + "date": "Datum", + "expectedCash": "Erwarteter Kassenbestand", + "failedToLoad": "Kasse konnte nicht geladen werden", + "failedToLoadMonthly": "Monatsbericht konnte nicht geladen werden", + "monthlyReport": "Monatsbericht", + "netCash": "Bargeld netto", + "noCounts": "Noch keine Zählungen", + "noPayments": "Noch keine Zahlungen", + "openingFloat": "Anfangsbestand", + "openingFloatAlreadySet": "Anfangsbestand für dieses Datum bereits gesetzt", + "openingFloatSet": "Anfangsbestand gesetzt", + "overall": "Gesamt", + "recordCount": "Zählung erfassen", + "recordedBy": "von {name}", + "selectDate": "Datum wählen", + "selectMonth": "Monat wählen", + "setOpeningFloat": "Anfangsbestand setzen", + "title": "Kasse", + "variance": "Differenz", + "varianceMatch": "Stimmt überein", + "varianceOverage": "+{amount} zu viel", + "varianceShortage": "-{amount} zu wenig" + }, "common": { "active": "Aktiv", "add": "Hinzufügen", @@ -149,6 +180,9 @@ "total": "Gesamt", "unknown": "Unbekannt", "update": "Aktualisieren", + "confirmVoid": "Diesen Eintrag stornieren? Der Betrag zählt dann nicht mehr.", + "void": "Stornieren", + "voided": "Storniert", "windowControls": "Fenstersteuerung", "yes": "Ja", "back": "Zurück" @@ -301,6 +335,43 @@ "refundsCount": "{count} Erstattungen", "ticketNoPayments": "Keine Zahlungen an diesem Tag" }, + "expenses": { + "addCategory": "Kategorie hinzufügen", + "addExpense": "Ausgabe hinzufügen", + "category": "Kategorie", + "categoryCreated": "Ausgabenkategorie erstellt", + "categoryDeleted": "Ausgabenkategorie gelöscht", + "categoryName": "Kategoriename", + "clearDateFilter": "Zurücksetzen", + "confirmDeleteCategory": "Kategorie \"{name}\" löschen? Kann nicht rückgängig gemacht werden.", + "date": "Datum", + "deleteCategory": "Kategorie löschen", + "deleteCategoryBlocked": "Offenen Betrag erst ausgleichen, dann löschen", + "due": "Offen", + "entryAdded": "Ausgabe erfasst", + "entryTypeExpense": "Ausgabe", + "entryTypePayment": "Zahlung", + "failedToLoad": "Ausgaben konnten nicht geladen werden", + "failedToLoadSummary": "Monatsbericht konnte nicht geladen werden", + "filterByDate": "Nach Datum filtern", + "history": "Letzte Aktivitäten", + "monthlyReport": "Monatsbericht", + "noCategories": "Noch keine Ausgabenkategorien", + "noEntries": "Noch keine Ausgaben", + "note": "Notiz", + "overall": "Gesamt", + "paymentMethod": "Zahlungsmethode", + "paymentMethodCard": "Karte", + "paymentMethodCash": "Bar", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Zahlung erfasst", + "recordPayment": "Zahlung erfassen", + "recordedBy": "von {name}", + "selectMonth": "Monat wählen", + "title": "Ausgaben", + "totalExpenses": "Ausgaben gesamt", + "totalPaid": "Gezahlt gesamt" + }, "kds": { "addonsLabel": "Zusätze", "authFailed": "Authentifizierung fehlgeschlagen", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket verbunden" }, "nav": { + "cashCounter": "Kasse", "collapse": "Einklappen", "confirmLogout": "Möchten Sie sich wirklich abmelden?", "customers": "Kunden", "dashboard": "Dashboard", + "expenses": "Ausgaben", "heapLabel": "Speicher: ", "kds": "KDS", "logout": "Abmelden", @@ -1920,7 +1993,8 @@ "settings": "Einstellungen", "integrations": "Integrationen", "system": "System", - "support": "Support" + "support": "Support", + "expenses": "Ausgaben" }, "capabilities": { "pos": "Kassenterminal bedienen", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Cloud-Konto- und Datenkontrollen verwalten", "databaseTools": "Datenbankwerkzeuge und Sicherungen nutzen", "serverApp": "Eigenständige Bedienungs-App verwenden", - "support": "Support kontaktieren und Diagnosedaten einsehen" + "support": "Support kontaktieren und Diagnosedaten einsehen", + "expenseCategoriesManage": "Ausgabenkategorien hinzufügen oder löschen", + "expenseEntriesRecord": "Ausgaben und Zahlungen erfassen", + "cashCounterRecord": "Anfangsbestände und Zählungen erfassen" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 2384736a3..babc104ca 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -86,6 +86,37 @@ "businessType": { "restaurant": "Restaurant" }, + "cashCounter": { + "cashExpenses": "Cash Expenses", + "cashFromOrders": "Cash from Orders", + "counted": "Counted", + "countedAmount": "Counted amount", + "cashRefunds": "Cash Refunds", + "countLog": "Count Log", + "countRecorded": "Cash count recorded", + "date": "Date", + "expectedCash": "Expected Cash", + "failedToLoad": "Failed to load the cash counter", + "failedToLoadMonthly": "Failed to load the monthly cash report", + "monthlyReport": "Monthly report", + "netCash": "Net cash", + "noCounts": "No counts recorded yet", + "noPayments": "No payments yet", + "openingFloat": "Opening Float", + "openingFloatAlreadySet": "Opening float already set for this date", + "openingFloatSet": "Opening float set", + "overall": "Overall", + "recordCount": "Record Count", + "recordedBy": "by {name}", + "selectDate": "Select date", + "selectMonth": "Select month", + "setOpeningFloat": "Set Opening Float", + "title": "Cash Counter", + "variance": "Variance", + "varianceMatch": "Matches", + "varianceOverage": "+{amount} over", + "varianceShortage": "-{amount} short" + }, "common": { "active": "Active", "add": "Add", @@ -149,6 +180,9 @@ "total": "Total", "unknown": "Unknown", "update": "Update", + "confirmVoid": "Void this entry? Its amount will no longer count.", + "void": "Void", + "voided": "Voided", "windowControls": "Window controls", "yes": "Yes", "back": "Back" @@ -301,6 +335,43 @@ "refundsCount": "{count} refunds", "ticketNoPayments": "No payments for this day" }, + "expenses": { + "addCategory": "Add Category", + "addExpense": "Add Expense", + "category": "Category", + "categoryCreated": "Expense category created", + "categoryDeleted": "Expense category deleted", + "categoryName": "Category name", + "clearDateFilter": "Clear", + "confirmDeleteCategory": "Delete the \"{name}\" category? This cannot be undone.", + "date": "Date", + "deleteCategory": "Delete category", + "deleteCategoryBlocked": "Settle the outstanding due before deleting this category", + "due": "Due", + "entryAdded": "Expense recorded", + "entryTypeExpense": "Expense", + "entryTypePayment": "Payment", + "failedToLoad": "Failed to load expenses", + "failedToLoadSummary": "Failed to load the monthly report", + "filterByDate": "Filter by date", + "history": "Recent activity", + "monthlyReport": "Monthly report", + "noCategories": "No expense categories yet", + "noEntries": "No expense activity yet", + "note": "Note", + "overall": "Overall", + "paymentMethod": "Payment method", + "paymentMethodCard": "Card", + "paymentMethodCash": "Cash", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Payment recorded", + "recordPayment": "Record Payment", + "recordedBy": "by {name}", + "selectMonth": "Select month", + "title": "Expenses", + "totalExpenses": "Total expenses", + "totalPaid": "Total paid" + }, "kds": { "addonsLabel": "Add-ons", "authFailed": "Authentication failed", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket connected" }, "nav": { + "cashCounter": "Cash Counter", "collapse": "Collapse", "confirmLogout": "Are you sure you want to log out?", "customers": "Customers", "dashboard": "Dashboard", + "expenses": "Expenses", "heapLabel": "Heap: ", "kds": "KDS", "logout": "Logout", @@ -1920,7 +1993,8 @@ "settings": "Settings", "integrations": "Integrations", "system": "System", - "support": "Support" + "support": "Support", + "expenses": "Expenses" }, "capabilities": { "pos": "Use the POS terminal", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Manage cloud account and data controls", "databaseTools": "Use database tools and backups", "serverApp": "Use the standalone Server App", - "support": "Contact support and view diagnostics" + "support": "Contact support and view diagnostics", + "expenseCategoriesManage": "Add or delete expense categories", + "expenseEntriesRecord": "Record expenses and due payments", + "cashCounterRecord": "Log opening floats and cash counts" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 0525232a5..0977310fb 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -86,6 +86,37 @@ "businessType": { "restaurant": "Restaurante" }, + "cashCounter": { + "cashExpenses": "Gastos en Efectivo", + "cashFromOrders": "Efectivo de Pedidos", + "counted": "Contado", + "countedAmount": "Monto contado", + "cashRefunds": "Reembolsos en efectivo", + "countLog": "Registro de Conteos", + "countRecorded": "Conteo de efectivo registrado", + "date": "Fecha", + "expectedCash": "Efectivo Esperado", + "failedToLoad": "No se pudo cargar el contador de efectivo", + "failedToLoadMonthly": "No se pudo cargar el informe mensual de efectivo", + "monthlyReport": "Informe mensual", + "netCash": "Efectivo neto", + "noCounts": "Todavía no hay conteos registrados", + "noPayments": "Todavía no hay pagos", + "openingFloat": "Fondo Inicial", + "openingFloatAlreadySet": "Ya se estableció el fondo inicial para esta fecha", + "openingFloatSet": "Fondo inicial establecido", + "overall": "Total general", + "recordCount": "Registrar Conteo", + "recordedBy": "por {name}", + "selectDate": "Seleccionar fecha", + "selectMonth": "Seleccionar mes", + "setOpeningFloat": "Establecer Fondo Inicial", + "title": "Contador de Efectivo", + "variance": "Diferencia", + "varianceMatch": "Coincide", + "varianceOverage": "+{amount} de sobrante", + "varianceShortage": "-{amount} de faltante" + }, "common": { "active": "Activo", "add": "Agregar", @@ -149,6 +180,9 @@ "total": "Total", "unknown": "Desconocido", "update": "Actualizar", + "confirmVoid": "¿Anular este registro? Su importe dejará de contar.", + "void": "Anular", + "voided": "Anulado", "windowControls": "Controles de ventana", "yes": "Sí", "back": "Atrás" @@ -301,6 +335,43 @@ "refundsCount": "{count} reembolsos", "ticketNoPayments": "No hay pagos en este día" }, + "expenses": { + "addCategory": "Agregar Categoría", + "addExpense": "Agregar Gasto", + "category": "Categoría", + "categoryCreated": "Categoría de gasto creada", + "categoryDeleted": "Categoría de gasto eliminada", + "categoryName": "Nombre de la categoría", + "clearDateFilter": "Limpiar", + "confirmDeleteCategory": "¿Eliminar la categoría \"{name}\"? Esta acción no se puede deshacer.", + "date": "Fecha", + "deleteCategory": "Eliminar categoría", + "deleteCategoryBlocked": "Salda la deuda pendiente antes de eliminar esta categoría", + "due": "Deuda", + "entryAdded": "Gasto registrado", + "entryTypeExpense": "Gasto", + "entryTypePayment": "Pago", + "failedToLoad": "No se pudieron cargar los gastos", + "failedToLoadSummary": "No se pudo cargar el informe mensual", + "filterByDate": "Filtrar por fecha", + "history": "Actividad reciente", + "monthlyReport": "Informe mensual", + "noCategories": "Todavía no hay categorías de gastos", + "noEntries": "Todavía no hay actividad de gastos", + "note": "Nota", + "overall": "Total general", + "paymentMethod": "Método de pago", + "paymentMethodCard": "Tarjeta", + "paymentMethodCash": "Efectivo", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Pago registrado", + "recordPayment": "Registrar Pago", + "recordedBy": "por {name}", + "selectMonth": "Seleccionar mes", + "title": "Gastos", + "totalExpenses": "Total de gastos", + "totalPaid": "Total pagado" + }, "kds": { "addonsLabel": "Adicionales", "authFailed": "Falló la autenticación", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket conectado" }, "nav": { + "cashCounter": "Contador de Efectivo", "collapse": "Contraer", "confirmLogout": "¿Estás seguro de que quieres cerrar la sesión?", "customers": "Clientes", "dashboard": "Panel", + "expenses": "Gastos", "heapLabel": "Memoria: ", "kds": "KDS", "logout": "Cerrar sesión", @@ -1920,7 +1993,8 @@ "settings": "Configuración", "integrations": "Integraciones", "system": "Sistema", - "support": "Soporte" + "support": "Soporte", + "expenses": "Gastos" }, "capabilities": { "pos": "Usar el terminal POS", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Gestionar la cuenta y los controles de datos en la nube", "databaseTools": "Usar herramientas y copias de seguridad de la base de datos", "serverApp": "Usar la aplicación independiente para servidores", - "support": "Contactar con soporte y ver diagnósticos" + "support": "Contactar con soporte y ver diagnósticos", + "expenseCategoriesManage": "Agregar o eliminar categorías de gastos", + "expenseEntriesRecord": "Registrar gastos y pagos de deuda", + "cashCounterRecord": "Registrar fondos iniciales y conteos de efectivo" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index b05b721ac..43832dc59 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -86,6 +86,37 @@ "businessType": { "restaurant": "رستوران" }, + "cashCounter": { + "cashExpenses": "هزینه‌های نقدی", + "cashFromOrders": "نقدی از سفارش‌ها", + "counted": "شمارش‌شده", + "countedAmount": "مبلغ شمارش‌شده", + "cashRefunds": "Cash Refunds", + "countLog": "گزارش شمارش‌ها", + "countRecorded": "شمارش نقدی ثبت شد", + "date": "تاریخ", + "expectedCash": "نقدی مورد انتظار", + "failedToLoad": "بارگذاری شمارشگر نقدی انجام نشد", + "failedToLoadMonthly": "بارگذاری گزارش ماهانه نقدی انجام نشد", + "monthlyReport": "گزارش ماهانه", + "netCash": "نقدی خالص", + "noCounts": "هنوز شمارشی ثبت نشده است", + "noPayments": "هنوز پرداختی وجود ندارد", + "openingFloat": "موجودی اولیه", + "openingFloatAlreadySet": "موجودی اولیه برای این تاریخ پیش‌تر تنظیم شده است", + "openingFloatSet": "موجودی اولیه تنظیم شد", + "overall": "مجموع کل", + "recordCount": "ثبت شمارش", + "recordedBy": "توسط {name}", + "selectDate": "انتخاب تاریخ", + "selectMonth": "انتخاب ماه", + "setOpeningFloat": "تنظیم موجودی اولیه", + "title": "شمارشگر نقدی", + "variance": "اختلاف", + "varianceMatch": "مطابقت دارد", + "varianceOverage": "{amount}+ مازاد", + "varianceShortage": "{amount}- کسری" + }, "common": { "active": "فعال", "add": "افزودن", @@ -149,6 +180,9 @@ "total": "جمع کل", "unknown": "ناشناخته", "update": "به‌روزرسانی", + "confirmVoid": "Void this entry? Its amount will no longer count.", + "void": "Void", + "voided": "Voided", "windowControls": "دکمه‌های پنجره", "yes": "بله", "back": "بازگشت" @@ -301,6 +335,43 @@ "refundsCount": "{count} بازپرداخت", "ticketNoPayments": "در این روز پرداختی نشده" }, + "expenses": { + "addCategory": "افزودن دسته", + "addExpense": "افزودن هزینه", + "category": "دسته", + "categoryCreated": "دسته هزینه ایجاد شد", + "categoryDeleted": "دسته هزینه حذف شد", + "categoryName": "نام دسته", + "clearDateFilter": "پاک کردن", + "confirmDeleteCategory": "دسته «{name}» حذف شود؟ این کار بازگشت‌پذیر نیست.", + "date": "تاریخ", + "deleteCategory": "حذف دسته", + "deleteCategoryBlocked": "پیش از حذف این دسته، بدهی باقی‌مانده را تسویه کنید", + "due": "بدهی", + "entryAdded": "هزینه ثبت شد", + "entryTypeExpense": "هزینه", + "entryTypePayment": "پرداخت", + "failedToLoad": "بارگذاری هزینه‌ها انجام نشد", + "failedToLoadSummary": "بارگذاری گزارش ماهانه انجام نشد", + "filterByDate": "پالایش بر اساس تاریخ", + "history": "فعالیت اخیر", + "monthlyReport": "گزارش ماهانه", + "noCategories": "هنوز دسته هزینه‌ای وجود ندارد", + "noEntries": "هنوز فعالیت هزینه‌ای وجود ندارد", + "note": "یادداشت", + "overall": "مجموع کل", + "paymentMethod": "روش پرداخت", + "paymentMethodCard": "کارت", + "paymentMethodCash": "نقدی", + "paymentMethodUpi": "UPI", + "paymentRecorded": "پرداخت ثبت شد", + "recordPayment": "ثبت پرداخت", + "recordedBy": "توسط {name}", + "selectMonth": "انتخاب ماه", + "title": "هزینه‌ها", + "totalExpenses": "مجموع هزینه‌ها", + "totalPaid": "مجموع پرداختی" + }, "kds": { "addonsLabel": "افزودنی‌ها", "authFailed": "احراز هویت انجام نشد", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket پیوند داده شد" }, "nav": { + "cashCounter": "شمارشگر نقدی", "collapse": "جمع کردن", "confirmLogout": "آیا از خروج مطمئن هستید؟", "customers": "مشتریان", "dashboard": "داشبورد", + "expenses": "هزینه‌ها", "heapLabel": "حافظه: ", "kds": "نمایشگر آشپزخانه", "logout": "خروج", @@ -1920,7 +1993,8 @@ "settings": "تنظیمات", "integrations": "یکپارچه‌سازی‌ها", "system": "سیستم", - "support": "پشتیبانی" + "support": "پشتیبانی", + "expenses": "هزینه‌ها" }, "capabilities": { "pos": "استفاده از پایانه فروش", @@ -1966,7 +2040,10 @@ "cloudAccountData": "مدیریت حساب و کنترل‌های داده ابری", "databaseTools": "استفاده از ابزار پایگاه‌داده و پشتیبان‌گیری", "serverApp": "استفاده از برنامه مستقل پیش‌خدمت", - "support": "تماس با پشتیبانی و مشاهده عیب‌یابی" + "support": "تماس با پشتیبانی و مشاهده عیب‌یابی", + "expenseCategoriesManage": "افزودن یا حذف دسته‌های هزینه", + "expenseEntriesRecord": "ثبت هزینه‌ها و پرداخت بدهی", + "cashCounterRecord": "ثبت موجودی اولیه و شمارش نقدی" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 222520b8e..a7894bf02 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -85,6 +85,37 @@ }, "businessType": { "restaurant": "Restaurant & Cafe" + }, + "cashCounter": { + "cashExpenses": "Cash Expenses", + "cashFromOrders": "Cash from Orders", + "counted": "Counted", + "countedAmount": "Counted amount", + "cashRefunds": "Cash Refunds", + "countLog": "Count Log", + "countRecorded": "Cash count recorded", + "date": "Date", + "expectedCash": "Expected Cash", + "failedToLoad": "Failed to load the cash counter", + "failedToLoadMonthly": "Failed to load the monthly cash report", + "monthlyReport": "Monthly report", + "netCash": "Net cash", + "noCounts": "No counts recorded yet", + "noPayments": "No payments yet", + "openingFloat": "Opening Float", + "openingFloatAlreadySet": "Opening float already set for this date", + "openingFloatSet": "Opening float set", + "overall": "Overall", + "recordCount": "Record Count", + "recordedBy": "by {name}", + "selectDate": "Select date", + "selectMonth": "Select month", + "setOpeningFloat": "Set Opening Float", + "title": "Cash Counter", + "variance": "Variance", + "varianceMatch": "Matches", + "varianceOverage": "+{amount} over", + "varianceShortage": "-{amount} short" }, "common": { "active": "Aktibo", @@ -149,6 +180,9 @@ "total": "Kabuuan", "unknown": "Hindi alam", "update": "I-update", + "confirmVoid": "Void this entry? Its amount will no longer count.", + "void": "Void", + "voided": "Voided", "windowControls": "Mga Kontrol sa Window", "yes": "Oo", "back": "Bumalik" @@ -300,6 +334,43 @@ "xReportSales": "Mga benta ngayong araw", "refundsCount": "{count} refundo", "ticketNoPayments": "Walang bayad sa araw na ito" + }, + "expenses": { + "addCategory": "Add Category", + "addExpense": "Add Expense", + "category": "Category", + "categoryCreated": "Expense category created", + "categoryDeleted": "Expense category deleted", + "categoryName": "Category name", + "clearDateFilter": "Clear", + "confirmDeleteCategory": "Delete the \"{name}\" category? This cannot be undone.", + "date": "Date", + "deleteCategory": "Delete category", + "deleteCategoryBlocked": "Settle the outstanding due before deleting this category", + "due": "Due", + "entryAdded": "Expense recorded", + "entryTypeExpense": "Expense", + "entryTypePayment": "Payment", + "failedToLoad": "Failed to load expenses", + "failedToLoadSummary": "Failed to load the monthly report", + "filterByDate": "Filter by date", + "history": "Recent activity", + "monthlyReport": "Monthly report", + "noCategories": "No expense categories yet", + "noEntries": "No expense activity yet", + "note": "Note", + "overall": "Overall", + "paymentMethod": "Payment method", + "paymentMethodCard": "Card", + "paymentMethodCash": "Cash", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Payment recorded", + "recordPayment": "Record Payment", + "recordedBy": "by {name}", + "selectMonth": "Select month", + "title": "Expenses", + "totalExpenses": "Total expenses", + "totalPaid": "Total paid" }, "kds": { "addonsLabel": "Mga Addon", @@ -346,10 +417,12 @@ "wsConnected": "Konektado ang WebSocket" }, "nav": { + "cashCounter": "Cash Counter", "collapse": "I-collapse ang Sidebar", "confirmLogout": "Sigurado ka bang gusto mong mag-sign out?", "customers": "Mga Customer", "dashboard": "Dashboard", + "expenses": "Expenses", "heapLabel": "Memory", "kds": "KDS", "logout": "Mag-sign Out", @@ -1920,7 +1993,8 @@ "settings": "Mga Setting", "integrations": "Mga Integration", "system": "System", - "support": "Suporta" + "support": "Suporta", + "expenses": "Expenses" }, "capabilities": { "pos": "Gamitin ang POS terminal", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Pamahalaan ang cloud account at mga kontrol sa data", "databaseTools": "Gamitin ang mga tool sa database at backup", "serverApp": "Gamitin ang standalone Server App", - "support": "Makipag-ugnayan sa suporta at tingnan ang diagnostics" + "support": "Makipag-ugnayan sa suporta at tingnan ang diagnostics", + "expenseCategoriesManage": "Add or delete expense categories", + "expenseEntriesRecord": "Record expenses and due payments", + "cashCounterRecord": "Log opening floats and cash counts" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index 4ddc4cac6..88c345940 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -86,6 +86,37 @@ "businessType": { "restaurant": "Restaurant" }, + "cashCounter": { + "cashExpenses": "Dépenses en espèces", + "cashFromOrders": "Encaissé en espèces", + "cashRefunds": "Remboursements en espèces", + "countLog": "Relevés de caisse", + "countRecorded": "Comptage enregistré", + "counted": "Compté", + "countedAmount": "Montant compté", + "date": "Date", + "expectedCash": "Espèces attendues", + "failedToLoad": "Échec du chargement de la caisse", + "failedToLoadMonthly": "Échec du chargement du rapport mensuel", + "monthlyReport": "Rapport mensuel", + "netCash": "Espèces nettes", + "noCounts": "Aucun comptage pour le moment", + "noPayments": "Aucun paiement pour le moment", + "openingFloat": "Fond de caisse", + "openingFloatAlreadySet": "Fond de caisse déjà défini pour cette date", + "openingFloatSet": "Fond de caisse défini", + "overall": "Total", + "recordCount": "Enregistrer un comptage", + "recordedBy": "par {name}", + "selectDate": "Choisir une date", + "selectMonth": "Choisir un mois", + "setOpeningFloat": "Définir le fond de caisse", + "title": "Caisse", + "variance": "Écart", + "varianceMatch": "Conforme", + "varianceOverage": "+{amount} en trop", + "varianceShortage": "-{amount} manquant" + }, "common": { "active": "Actif", "add": "Ajouter", @@ -149,6 +180,9 @@ "total": "Total", "unknown": "Inconnu", "update": "Mettre à jour", + "confirmVoid": "Annuler cette écriture ? Son montant ne sera plus compté.", + "void": "Annuler", + "voided": "Annulé", "windowControls": "Commandes de la fenêtre", "yes": "Oui", "back": "Retour" @@ -301,6 +335,43 @@ "refundsCount": "{count} remboursements", "ticketNoPayments": "Aucun paiement ce jour" }, + "expenses": { + "addCategory": "Ajouter une catégorie", + "addExpense": "Ajouter une dépense", + "category": "Catégorie", + "categoryCreated": "Catégorie de dépense créée", + "categoryDeleted": "Catégorie de dépense supprimée", + "categoryName": "Nom de la catégorie", + "clearDateFilter": "Effacer", + "confirmDeleteCategory": "Supprimer la catégorie \"{name}\" ? Action irréversible.", + "date": "Date", + "deleteCategory": "Supprimer la catégorie", + "deleteCategoryBlocked": "Soldez le montant dû avant de supprimer cette catégorie", + "due": "Dû", + "entryAdded": "Dépense enregistrée", + "entryTypeExpense": "Dépense", + "entryTypePayment": "Paiement", + "failedToLoad": "Échec du chargement des dépenses", + "failedToLoadSummary": "Échec du chargement du rapport mensuel", + "filterByDate": "Filtrer par date", + "history": "Activité récente", + "monthlyReport": "Rapport mensuel", + "noCategories": "Aucune catégorie de dépense", + "noEntries": "Aucune dépense pour le moment", + "note": "Note", + "overall": "Total", + "paymentMethod": "Mode de paiement", + "paymentMethodCard": "Carte", + "paymentMethodCash": "Espèces", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Paiement enregistré", + "recordPayment": "Enregistrer un paiement", + "recordedBy": "par {name}", + "selectMonth": "Choisir un mois", + "title": "Dépenses", + "totalExpenses": "Dépenses totales", + "totalPaid": "Total payé" + }, "kds": { "addonsLabel": "Options", "authFailed": "Échec de l’authentification", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket connecté" }, "nav": { + "cashCounter": "Caisse", "collapse": "Réduire", "confirmLogout": "Voulez-vous vraiment vous déconnecter ?", "customers": "Clients", "dashboard": "Tableau de bord", + "expenses": "Dépenses", "heapLabel": "Tas : ", "kds": "KDS", "logout": "Déconnexion", @@ -1920,7 +1993,8 @@ "settings": "Paramètres", "integrations": "Intégrations", "system": "Système", - "support": "Assistance" + "support": "Assistance", + "expenses": "Dépenses" }, "capabilities": { "pos": "Utiliser le terminal de caisse", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Gérer le compte infonuagique et les paramètres de données", "databaseTools": "Utiliser les outils de base de données et les sauvegardes", "serverApp": "Utiliser l’application serveur autonome", - "support": "Contacter l’assistance et afficher les diagnostics" + "support": "Contacter l’assistance et afficher les diagnostics", + "expenseCategoriesManage": "Ajouter ou supprimer les catégories de dépenses", + "expenseEntriesRecord": "Enregistrer les dépenses et les paiements", + "cashCounterRecord": "Enregistrer les fonds de caisse et les comptages" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 0e454b0f7..2e38a9a59 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -86,6 +86,37 @@ "businessType": { "restaurant": "Restaurante" }, + "cashCounter": { + "cashExpenses": "Despesas em Dinheiro", + "cashFromOrders": "Dinheiro de Pedidos", + "counted": "Contado", + "countedAmount": "Valor contado", + "cashRefunds": "Reembolsos em dinheiro", + "countLog": "Registro de Contagens", + "countRecorded": "Contagem de caixa registrada", + "date": "Data", + "expectedCash": "Caixa Esperado", + "failedToLoad": "Falha ao carregar o contador de caixa", + "failedToLoadMonthly": "Falha ao carregar o relatório mensal de caixa", + "monthlyReport": "Relatório mensal", + "netCash": "Caixa líquido", + "noCounts": "Ainda não há contagens registradas", + "noPayments": "Ainda não há pagamentos", + "openingFloat": "Fundo de Troco", + "openingFloatAlreadySet": "O fundo de troco já foi definido para esta data", + "openingFloatSet": "Fundo de troco definido", + "overall": "Total geral", + "recordCount": "Registrar Contagem", + "recordedBy": "por {name}", + "selectDate": "Selecionar data", + "selectMonth": "Selecionar mês", + "setOpeningFloat": "Definir Fundo de Troco", + "title": "Contador de Caixa", + "variance": "Diferença", + "varianceMatch": "Confere", + "varianceOverage": "+{amount} de sobra", + "varianceShortage": "-{amount} de falta" + }, "common": { "active": "Ativo", "add": "Adicionar", @@ -149,6 +180,9 @@ "total": "Total", "unknown": "Desconhecido", "update": "Atualizar", + "confirmVoid": "Anular este lançamento? O valor deixará de contar.", + "void": "Anular", + "voided": "Anulado", "windowControls": "Controles da janela", "yes": "Sim", "back": "Voltar" @@ -301,6 +335,43 @@ "refundsCount": "{count} reembolsos", "ticketNoPayments": "Sem pagamentos neste dia" }, + "expenses": { + "addCategory": "Adicionar Categoria", + "addExpense": "Adicionar Despesa", + "category": "Categoria", + "categoryCreated": "Categoria de despesa criada", + "categoryDeleted": "Categoria de despesa excluída", + "categoryName": "Nome da categoria", + "clearDateFilter": "Limpar", + "confirmDeleteCategory": "Excluir a categoria \"{name}\"? Esta ação não pode ser desfeita.", + "date": "Data", + "deleteCategory": "Excluir categoria", + "deleteCategoryBlocked": "Quite o saldo devedor antes de excluir esta categoria", + "due": "Saldo devedor", + "entryAdded": "Despesa registrada", + "entryTypeExpense": "Despesa", + "entryTypePayment": "Pagamento", + "failedToLoad": "Falha ao carregar despesas", + "failedToLoadSummary": "Falha ao carregar o relatório mensal", + "filterByDate": "Filtrar por data", + "history": "Atividade recente", + "monthlyReport": "Relatório mensal", + "noCategories": "Ainda não há categorias de despesas", + "noEntries": "Ainda não há atividade de despesas", + "note": "Nota", + "overall": "Total geral", + "paymentMethod": "Método de pagamento", + "paymentMethodCard": "Cartão", + "paymentMethodCash": "Dinheiro", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Pagamento registrado", + "recordPayment": "Registrar Pagamento", + "recordedBy": "por {name}", + "selectMonth": "Selecionar mês", + "title": "Despesas", + "totalExpenses": "Total de despesas", + "totalPaid": "Total pago" + }, "kds": { "addonsLabel": "Adicionais", "authFailed": "Falha na autenticação", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket conectado" }, "nav": { + "cashCounter": "Contador de Caixa", "collapse": "Recolher", "confirmLogout": "Tem certeza de que deseja sair?", "customers": "Clientes", "dashboard": "Painel", + "expenses": "Despesas", "heapLabel": "Heap: ", "kds": "KDS", "logout": "Sair", @@ -1920,7 +1993,8 @@ "settings": "Configurações", "integrations": "Integrações", "system": "Sistema", - "support": "Suporte" + "support": "Suporte", + "expenses": "Despesas" }, "capabilities": { "pos": "Usar o terminal POS", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Gerenciar a conta e os controles de dados na nuvem", "databaseTools": "Usar ferramentas e backups do banco de dados", "serverApp": "Usar o aplicativo independente do servidor", - "support": "Contatar o suporte e ver diagnósticos" + "support": "Contatar o suporte e ver diagnósticos", + "expenseCategoriesManage": "Adicionar ou excluir categorias de despesas", + "expenseEntriesRecord": "Registrar despesas e pagamentos de dívidas", + "cashCounterRecord": "Registrar fundos de troco e contagens de caixa" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 7a1f770ff..8bea299e1 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -85,6 +85,37 @@ }, "businessType": { "restaurant": "Restoran & Kafe" + }, + "cashCounter": { + "cashExpenses": "Cash Expenses", + "cashFromOrders": "Cash from Orders", + "counted": "Counted", + "countedAmount": "Counted amount", + "cashRefunds": "Cash Refunds", + "countLog": "Count Log", + "countRecorded": "Cash count recorded", + "date": "Date", + "expectedCash": "Expected Cash", + "failedToLoad": "Failed to load the cash counter", + "failedToLoadMonthly": "Failed to load the monthly cash report", + "monthlyReport": "Monthly report", + "netCash": "Net cash", + "noCounts": "No counts recorded yet", + "noPayments": "No payments yet", + "openingFloat": "Opening Float", + "openingFloatAlreadySet": "Opening float already set for this date", + "openingFloatSet": "Opening float set", + "overall": "Overall", + "recordCount": "Record Count", + "recordedBy": "by {name}", + "selectDate": "Select date", + "selectMonth": "Select month", + "setOpeningFloat": "Set Opening Float", + "title": "Cash Counter", + "variance": "Variance", + "varianceMatch": "Matches", + "varianceOverage": "+{amount} over", + "varianceShortage": "-{amount} short" }, "common": { "active": "Aktif", @@ -149,6 +180,9 @@ "total": "Toplam", "unknown": "Bilinmiyor", "update": "Güncelle", + "confirmVoid": "Void this entry? Its amount will no longer count.", + "void": "Void", + "voided": "Voided", "windowControls": "Pencere Kontrolleri", "yes": "Evet", "back": "Geri" @@ -300,6 +334,43 @@ "xReportSales": "Bugünkü satışlar", "refundsCount": "{count} iade", "ticketNoPayments": "Bu gün ödeme yok" + }, + "expenses": { + "addCategory": "Add Category", + "addExpense": "Add Expense", + "category": "Category", + "categoryCreated": "Expense category created", + "categoryDeleted": "Expense category deleted", + "categoryName": "Category name", + "clearDateFilter": "Clear", + "confirmDeleteCategory": "Delete the \"{name}\" category? This cannot be undone.", + "date": "Date", + "deleteCategory": "Delete category", + "deleteCategoryBlocked": "Settle the outstanding due before deleting this category", + "due": "Due", + "entryAdded": "Expense recorded", + "entryTypeExpense": "Expense", + "entryTypePayment": "Payment", + "failedToLoad": "Failed to load expenses", + "failedToLoadSummary": "Failed to load the monthly report", + "filterByDate": "Filter by date", + "history": "Recent activity", + "monthlyReport": "Monthly report", + "noCategories": "No expense categories yet", + "noEntries": "No expense activity yet", + "note": "Note", + "overall": "Overall", + "paymentMethod": "Payment method", + "paymentMethodCard": "Card", + "paymentMethodCash": "Cash", + "paymentMethodUpi": "UPI", + "paymentRecorded": "Payment recorded", + "recordPayment": "Record Payment", + "recordedBy": "by {name}", + "selectMonth": "Select month", + "title": "Expenses", + "totalExpenses": "Total expenses", + "totalPaid": "Total paid" }, "kds": { "addonsLabel": "Eklentiler", @@ -346,10 +417,12 @@ "wsConnected": "WebSocket Bağlı" }, "nav": { + "cashCounter": "Cash Counter", "collapse": "Menüyü Daralt", "confirmLogout": "Çıkış yapmak istediğinizden emin misiniz?", "customers": "Müşteriler", "dashboard": "Genel Bakış", + "expenses": "Expenses", "heapLabel": "Bellek (Heap)", "kds": "Mutfak Ekranı", "logout": "Çıkış Yap", @@ -1920,7 +1993,8 @@ "settings": "Ayarlar", "integrations": "Entegrasyonlar", "system": "Sistem", - "support": "Destek" + "support": "Destek", + "expenses": "Expenses" }, "capabilities": { "pos": "Satış noktasını (POS) kullanma ve sipariş girme", @@ -1966,7 +2040,10 @@ "cloudAccountData": "Bulut hesabı ve veri kontrollerini yönetme", "databaseTools": "Veritabanı araçlarını ve yedeklemeyi kullanma", "serverApp": "Bağımsız Garson Uygulamasını kullanma", - "support": "Destek ekibiyle iletişime geçme ve sistem tanılamayı görme" + "support": "Destek ekibiyle iletişime geçme ve sistem tanılamayı görme", + "expenseCategoriesManage": "Add or delete expense categories", + "expenseEntriesRecord": "Record expenses and due payments", + "cashCounterRecord": "Log opening floats and cash counts" } }, "support": { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e96540fd0..439938f8d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -259,6 +259,119 @@ export interface Staff { updated_at: string; } +export interface ExpenseCategory { + id: string; + name: string; + is_active: boolean; + total_expenses: number; + total_payments: number; + due: number; + created_at: string; + updated_at: string; +} + +export type ExpensePaymentMethod = 'cash' | 'card' | 'upi'; + +export interface ExpenseLedgerEntry { + id: number; + category_id: string; + category_name: string; + amount: number; + note: string | null; + date: string; + method: string | null; + created_by: string | null; + created_by_name: string | null; + created_at: string; +} + +export interface ExpenseMonthSummaryCategory { + category_id: string; + category_name: string; + due: number; + total_expenses: number; + total_payments: number; + payments_by_method: Record; + custom_payments: Record; +} + +export interface ExpenseMonthSummary { + month: string; + from: string; + to: string; + categories: ExpenseMonthSummaryCategory[]; + overall: { + total_expenses: number; + total_payments: number; + payments_by_method: Record; + custom_payments: Record; + }; +} + +export interface CashOpeningFloat { + id: number; + date: string; + amount: number; + note: string | null; + created_by: string | null; + created_by_name: string | null; + created_at: string; +} + +export interface CashCountRecord { + id: number; + date: string; + counted_amount: number; + note: string | null; + created_by: string | null; + created_by_name: string | null; + created_at: string; +} + +export interface CashOrderPayment { + bill_id: number; + bill_number: string; + amount: number; + payment_time: string; +} + +export interface CashDailySummary { + date: string; + opening_float: CashOpeningFloat | null; + cash_from_orders: { total: number; payments: CashOrderPayment[] }; + cash_refunds: { total: number }; + cash_expenses: { total: number; payments: ExpenseLedgerEntry[] }; + expected_cash: number; + counts: CashCountRecord[]; + latest_count: CashCountRecord | null; + variance: number | null; +} + +export interface CashMonthlyDay { + date: string; + opening_float: number; + cash_from_orders: number; + cash_refunds: number; + cash_expenses: number; + expected_cash: number; + latest_count: number | null; + variance: number | null; +} + +export interface CashMonthlySummary { + month: string; + from: string; + to: string; + days: CashMonthlyDay[]; + totals: { + total_opening_floats: number; + total_cash_from_orders: number; + total_cash_refunds: number; + total_cash_expenses: number; + net: number; + }; +} + export interface KitchenStation { id: number; name: string; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a646579ae..f6302b6ce 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -11,3 +11,25 @@ export function parseDbTimestamp(ts: string | null | undefined): Date { if (!ts) return new Date(NaN) return /^\d{4}-\d{2}-\d{2} /.test(ts) ? new Date(`${ts.replace(' ', 'T')}Z`) : new Date(ts) } + +// UTC calendar day — matches the backend's utcTodayDate() convention, so a +// date picked here is never rejected as "in the future" by a client whose +// local clock has already rolled past midnight UTC. +export function todayUtcDate(): string { + return new Date().toISOString().slice(0, 10) +} + +export function currentUtcMonth(): string { + return todayUtcDate().slice(0, 7) +} + +// Store-local calendar day (YYYY-MM-DD) for business-date defaults and picker +// limits. Falls back to the UTC day when the zone is missing or invalid. +export function todayInTimezone(timezone: string | null | undefined): string { + if (!timezone) return todayUtcDate() + try { + return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) + } catch { + return todayUtcDate() + } +} diff --git a/main/db.ts b/main/db.ts index 32a842995..db3455507 100644 --- a/main/db.ts +++ b/main/db.ts @@ -3913,8 +3913,7 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ 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); - `); + CREATE INDEX IF NOT EXISTS idx_refund_idempotency_bill ON refund_idempotency(bill_id); `); }, }, { @@ -3930,8 +3929,7 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ 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)`); - } + db.exec(`ALTER TABLE products ADD COLUMN weight_precision INTEGER NOT NULL DEFAULT 3 CHECK (weight_precision BETWEEN 0 AND 4)`); } }, }, { @@ -4022,7 +4020,122 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE UNIQUE INDEX IF NOT EXISTS cash_closures_one_day - ON cash_closures(business_date) WHERE scope = 'day'; + ON cash_closures(business_date) WHERE scope = 'day'; `); + }, + }, + { + // One migration for the whole expense/cash-counter feature: these tables + // never shipped in a release, so there is no old install carrying the + // intermediate shapes and nothing to preserve step by step. + version: 82, + name: 'add_expense_tracker_and_cash_counter', + up: () => { + db.exec(` + CREATE TABLE IF NOT EXISTS expense_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + deleted_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT REFERENCES users(id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_expense_categories_name_active + ON expense_categories(name COLLATE NOCASE) WHERE deleted_at IS NULL; + + CREATE TABLE IF NOT EXISTS expense_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id TEXT NOT NULL REFERENCES expense_categories(id), + amount REAL NOT NULL CHECK (amount > 0), + note TEXT, + expense_date TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_expense_entries_category ON expense_entries(category_id, created_at); + CREATE INDEX IF NOT EXISTS idx_expense_entries_created_at ON expense_entries(created_at); + CREATE INDEX IF NOT EXISTS idx_expense_entries_category_date ON expense_entries(category_id, expense_date); + CREATE INDEX IF NOT EXISTS idx_expense_entries_date ON expense_entries(expense_date); + + CREATE TABLE IF NOT EXISTS expense_due_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id TEXT NOT NULL REFERENCES expense_categories(id), + amount REAL NOT NULL CHECK (amount > 0), + note TEXT, + payment_date TEXT, + method TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_category ON expense_due_payments(category_id, created_at); + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_created_at ON expense_due_payments(created_at); + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_category_date ON expense_due_payments(category_id, payment_date); + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_date ON expense_due_payments(payment_date); + + CREATE TABLE IF NOT EXISTS cash_opening_floats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL UNIQUE, + amount REAL NOT NULL CHECK (amount >= 0), + note TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_cash_opening_floats_date ON cash_opening_floats(date); + + CREATE TABLE IF NOT EXISTS cash_count_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + counted_amount REAL NOT NULL CHECK (counted_amount >= 0), + note TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_cash_count_records_date ON cash_count_records(date, created_at); + `); + }, + }, + { + // Corrections without rewriting history: voiding stamps voided_at and + // every sum/ledger read ignores voided rows. The row stays as the audit + // trail; staff re-enter the correct figure as a new row. + version: 83, + name: 'add_finance_void_flags', + up: () => { + for (const table of ['expense_entries', 'expense_due_payments']) { + if (!getColumns(db, table).includes('voided_at')) { + db.exec(`ALTER TABLE ${table} ADD COLUMN voided_at TEXT`); + } + } + // A voided opening float must not block its replacement, so uniqueness + // moves from the inline constraint to a live-rows-only partial index. + // (SQLite cannot drop an inline UNIQUE; the table is rebuilt around it.) + const floatColumns = getColumns(db, 'cash_opening_floats'); + if (!floatColumns.includes('voided_at')) { + db.exec(` + CREATE TABLE cash_opening_floats_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + amount REAL NOT NULL CHECK (amount >= 0), + note TEXT, + voided_at TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + INSERT INTO cash_opening_floats_new (id, date, amount, note, created_by, created_at) + SELECT id, date, amount, note, created_by, created_at FROM cash_opening_floats; + DROP TABLE cash_opening_floats; + ALTER TABLE cash_opening_floats_new RENAME TO cash_opening_floats; + CREATE INDEX IF NOT EXISTS idx_cash_opening_floats_date ON cash_opening_floats(date); + `); + } + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_cash_opening_floats_live_date + ON cash_opening_floats(date) WHERE voided_at IS NULL; `); }, }, @@ -4151,6 +4264,101 @@ function createSchema(): void { updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS expense_categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + deleted_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by TEXT REFERENCES users(id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_expense_categories_name_active + ON expense_categories(name COLLATE NOCASE) WHERE deleted_at IS NULL; + + -- Append-only: no UPDATE/DELETE statement ever targets this table. Each + -- row increases the owed balance for its category (see expense_due_payments). + CREATE TABLE IF NOT EXISTS expense_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id TEXT NOT NULL REFERENCES expense_categories(id), + amount REAL NOT NULL CHECK (amount > 0), + note TEXT, + -- Business date the expense was for (YYYY-MM-DD, UTC calendar day — + -- see utcTodayDate()); may be backdated by the caller. Distinct from + -- created_at, which is the immutable moment the row was recorded. + -- Nullable at the schema level (not NOT NULL/DEFAULT); every insert + -- path in main/routes/expenses.ts always supplies a value. + expense_date TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Void stamp for typo corrections (see v83): voided rows stay as the + -- audit trail while every sum and ledger read ignores them. + voided_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_expense_entries_category ON expense_entries(category_id, created_at); + CREATE INDEX IF NOT EXISTS idx_expense_entries_created_at ON expense_entries(created_at); + CREATE INDEX IF NOT EXISTS idx_expense_entries_category_date ON expense_entries(category_id, expense_date); + CREATE INDEX IF NOT EXISTS idx_expense_entries_date ON expense_entries(expense_date); + + -- Append-only, same as expense_entries. Each row reduces the owed balance + -- for its category; due = SUM(expense_entries) - SUM(expense_due_payments). + CREATE TABLE IF NOT EXISTS expense_due_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id TEXT NOT NULL REFERENCES expense_categories(id), + amount REAL NOT NULL CHECK (amount > 0), + note TEXT, + -- Business date the payment was made (YYYY-MM-DD, UTC calendar day); + -- may be backdated. Distinct from created_at (see expense_entries). + -- Nullable at the schema level — see expense_entries.expense_date. + payment_date TEXT, + -- How the payment was settled: 'cash' | 'card' | 'upi', validated in + -- main/routes/expenses.ts, not by a DB CHECK constraint. + method TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Void stamp, same as expense_entries.voided_at (see v83). + voided_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_category ON expense_due_payments(category_id, created_at); + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_created_at ON expense_due_payments(created_at); + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_category_date ON expense_due_payments(category_id, payment_date); + CREATE INDEX IF NOT EXISTS idx_expense_due_payments_date ON expense_due_payments(payment_date); + + -- Append-only, one per calendar day. The starting cash amount for the + -- Cash Counter's daily reconciliation (main/routes/cash-counter.ts). + CREATE TABLE IF NOT EXISTS cash_opening_floats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + amount REAL NOT NULL CHECK (amount >= 0), + note TEXT, + -- Void stamp, same as expense_entries.voided_at (see v83). Uniqueness + -- applies to live rows only, so a voided float never blocks its re-entry. + voided_at TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_cash_opening_floats_date ON cash_opening_floats(date); + CREATE UNIQUE INDEX IF NOT EXISTS idx_cash_opening_floats_live_date + ON cash_opening_floats(date) WHERE voided_at IS NULL; + + -- Append-only. A staff-logged physical cash count for a day — purely a + -- reference fact compared against the calculated expected_cash; it never + -- overrides or replaces the calculated figure (see cash-counter.ts). + CREATE TABLE IF NOT EXISTS cash_count_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + counted_amount REAL NOT NULL CHECK (counted_amount >= 0), + note TEXT, + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS idx_cash_count_records_date ON cash_count_records(date, created_at); + CREATE TABLE IF NOT EXISTS products ( id TEXT PRIMARY KEY, category_id TEXT, diff --git a/main/routes/cash-counter.ts b/main/routes/cash-counter.ts new file mode 100644 index 000000000..b0476fa82 --- /dev/null +++ b/main/routes/cash-counter.ts @@ -0,0 +1,319 @@ +import { Router, Request, Response } from 'express'; +import expressRateLimit from 'express-rate-limit'; +import { dayBoundsInTimezone, getDatabase, localDateInTimezone, now, parseDbTimestamp } from '../db'; +import { getCurrencyMinorUnitFactor } from '../countries'; +import { getTenantCurrency } from '../services/refund'; +import { requireRole } from '../middleware/security'; +import { ROLE_ACCESS } from '../../shared/role-permissions'; +import { + monthBounds, + normalizeBusinessDate, + normalizeNote, + roundMoney, + storeToday, + tenantTimezone, +} from './finance-shared'; + +function normalizeNonNegativeAmount(value: unknown, field: string): number { + const amount = Number(value); + if (!Number.isFinite(amount) || amount < 0) { + throw Object.assign(new Error(`${field} must be a non-negative number`), { statusCode: 400 }); + } + return roundMoney(amount); +} + +const router = Router(); +const cashCounterWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); + +/** Every YYYY-MM-DD date from `from` to `to`, inclusive. */ +function datesInRange(from: string, to: string): string[] { + const dates: string[] = []; + const cursor = new Date(`${from}T00:00:00Z`); + const end = new Date(`${to}T00:00:00Z`); + while (cursor.getTime() <= end.getTime()) { + dates.push(cursor.toISOString().slice(0, 10)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return dates; +} + +type CashOrderPaymentLine = { bill_id: number; bill_number: string; amount: number; payment_time: string }; + +/** + * Cash payment lines for bills paid inside a store-timezone day window. + * Same drawer-reality rule as the Z day-close (main/routes/cash-closures.ts): + * lines are keyed by the bill's paid_at, not per-line timestamps, so an + * installment lands on the settlement day; unpaid bills have no paid_at + * and drop out on their own, no payment_status filter needed. Returns + * row-level lines (the Z only aggregates) for the daily breakdown. + */ +function cashOrderPaymentLines(db: ReturnType, start: string, end: string): CashOrderPaymentLine[] { + return db.prepare(` + SELECT b.id AS bill_id, b.bill_number, + CASE WHEN typeof(json_extract(je.value, '$.amount')) IN ('integer', 'real') + THEN json_extract(je.value, '$.amount') ELSE 0 END AS amount, + b.paid_at AS payment_time + FROM bills b + JOIN json_each(CASE + WHEN json_valid(b.payment_details) AND json_type(b.payment_details) = 'array' + THEN b.payment_details + WHEN json_valid(b.payment_details) + THEN json_array(b.payment_details) + ELSE '[]' + END) je + WHERE b.paid_at >= ? AND b.paid_at < ? + AND json_type(je.value) = 'object' + AND COALESCE(NULLIF(json_extract(je.value, '$.method'), ''), 'unknown') = 'cash' + ORDER BY b.paid_at DESC, b.id DESC + `).all(start, end) as CashOrderPaymentLine[]; +} + +// Cash that left the drawer as refunds, by the day the refund was issued +// (refunds.created_at) — same as the Z day-close. Stored in minor units, +// converted at the boundary like every other major-unit figure here. +function cashRefundsTotal(db: ReturnType, start: string, end: string): number { + const row = db.prepare(` + SELECT COALESCE(SUM(amount_cents), 0) AS cents FROM refunds + WHERE method = 'cash' AND created_at >= ? AND created_at < ? + `).get(start, end) as { cents: number }; + const factor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); + return roundMoney(row.cents / factor); +} + +// Same totals as cashRefundsTotal but bucketed per store-local day, mirroring +// how the monthly handler buckets order lines below: one range query, then +// group in JS on the store-local calendar date. +function cashRefundsByDate(db: ReturnType, start: string, end: string, timezone: string): Map { + const factor = getCurrencyMinorUnitFactor(getTenantCurrency(db)); + const rows = db.prepare(` + SELECT created_at, amount_cents AS cents FROM refunds + WHERE method = 'cash' AND created_at >= ? AND created_at < ? + `).all(start, end) as { created_at: string; cents: number }[]; + const byDate = new Map(); + for (const row of rows) { + const day = localDateInTimezone(parseDbTimestamp(row.created_at), timezone); + byDate.set(day, roundMoney((byDate.get(day) || 0) + row.cents / factor)); + } + return byDate; +} + +function listCashExpensePayments(db: ReturnType, date: string) { + return db.prepare(` + SELECT t.*, t.payment_date AS date, ec.name AS category_name, u.name AS created_by_name + FROM expense_due_payments t + JOIN expense_categories ec ON ec.id = t.category_id + LEFT JOIN users u ON u.id = t.created_by + WHERE t.method = 'cash' AND t.payment_date = ? AND t.voided_at IS NULL + ORDER BY t.created_at DESC, t.id DESC + `).all(date); +} + +function cashExpenseTotalsByDate(db: ReturnType, from: string, to: string): Map { + const rows = db.prepare(` + SELECT payment_date AS date, COALESCE(SUM(amount), 0) AS total + FROM expense_due_payments + WHERE method = 'cash' AND payment_date >= ? AND payment_date <= ? AND voided_at IS NULL + GROUP BY payment_date + `).all(from, to) as { date: string; total: number }[]; + return new Map(rows.map((row) => [row.date, row.total])); +} + +function openingFloatsByDate(db: ReturnType, from: string, to: string): Map { + const rows = db.prepare(` + SELECT date, amount FROM cash_opening_floats WHERE date >= ? AND date <= ? + `).all(from, to) as { date: string; amount: number }[]; + return new Map(rows.map((row) => [row.date, row.amount])); +} + +/** Latest count per day, via a window function — one guaranteed-correct row per date, ranked by created_at/id. */ +function latestCountsByDate(db: ReturnType, from: string, to: string): Map { + const rows = db.prepare(` + SELECT date, counted_amount FROM ( + SELECT date, counted_amount, + ROW_NUMBER() OVER (PARTITION BY date ORDER BY created_at DESC, id DESC) AS rn + FROM cash_count_records + WHERE date >= ? AND date <= ? + ) WHERE rn = 1 + `).all(from, to) as { date: string; counted_amount: number }[]; + return new Map(rows.map((row) => [row.date, row.counted_amount])); +} + +function expectedCash(opening: number, orders: number, refunds: number, expenses: number): number { + return roundMoney(opening + orders - refunds - expenses); +} + +router.get('/daily', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const date = normalizeBusinessDate(req.query.date); + const db = getDatabase(); + const [start, end] = dayBoundsInTimezone(date, tenantTimezone()); + + const openingFloat = db.prepare(` + SELECT f.*, u.name AS created_by_name + FROM cash_opening_floats f + LEFT JOIN users u ON u.id = f.created_by + WHERE f.date = ? AND f.voided_at IS NULL + `).get(date) as any; + + const orderLines = cashOrderPaymentLines(db, start, end); + const orderTotal = roundMoney(orderLines.reduce((sum, line) => sum + line.amount, 0)); + + const refundTotal = cashRefundsTotal(db, start, end); + + const expensePayments = listCashExpensePayments(db, date) as any[]; + const expenseTotal = roundMoney(expensePayments.reduce((sum, row) => sum + row.amount, 0)); + + const openingAmount = openingFloat?.amount ?? 0; + const expected = expectedCash(openingAmount, orderTotal, refundTotal, expenseTotal); + + const counts = db.prepare(` + SELECT c.*, u.name AS created_by_name + FROM cash_count_records c + LEFT JOIN users u ON u.id = c.created_by + WHERE c.date = ? + ORDER BY c.created_at DESC, c.id DESC + `).all(date) as any[]; + const latestCount = counts[0] ?? null; + const variance = latestCount ? roundMoney(latestCount.counted_amount - expected) : null; + + res.json({ + date, + opening_float: openingFloat || null, + cash_from_orders: { total: orderTotal, payments: orderLines }, + cash_refunds: { total: refundTotal }, + cash_expenses: { total: expenseTotal, payments: expensePayments }, + expected_cash: expected, + counts, + latest_count: latestCount, + variance, + }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to load the daily cash counter' }); + } +}); + +router.post('/opening-float', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const date = normalizeBusinessDate(req.body?.date); + const amount = normalizeNonNegativeAmount(req.body?.amount, 'amount'); + const note = normalizeNote(req.body?.note); + const db = getDatabase(); + const result = db.prepare(` + INSERT INTO cash_opening_floats (date, amount, note, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(date, amount, note, (req as any).user.userId, now()); + const opening_float = db.prepare(` + SELECT f.*, u.name AS created_by_name FROM cash_opening_floats f LEFT JOIN users u ON u.id = f.created_by WHERE f.id = ? + `).get(result.lastInsertRowid); + res.status(201).json({ opening_float }); + } catch (error: any) { + const duplicate = String(error.message || '').includes('UNIQUE constraint'); + res.status(duplicate ? 409 : error.statusCode || 500).json({ error: duplicate ? 'An opening float is already set for this date' : error.message || 'Unable to set the opening float' }); + } +}); + +router.post('/opening-float/:id/void', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: Response) => { + // Same void rule as expense rows: the voided float stops feeding expected + // cash while staying in the table, and the live-rows-only unique index + // lets staff set the corrected float for the same date right away. + const db = getDatabase(); + const result = db.prepare('UPDATE cash_opening_floats SET voided_at = ? WHERE id = ? AND voided_at IS NULL').run(now(), String(req.params.id)); + if (result.changes === 0) return res.status(404).json({ error: 'Opening float not found or already voided' }); + const opening_float = db.prepare(` + SELECT f.*, u.name AS created_by_name FROM cash_opening_floats f LEFT JOIN users u ON u.id = f.created_by WHERE f.id = ? + `).get(String(req.params.id)); + res.json({ opening_float }); +}); + +router.post('/count', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const date = normalizeBusinessDate(req.body?.date); + const counted_amount = normalizeNonNegativeAmount(req.body?.counted_amount, 'counted_amount'); + const note = normalizeNote(req.body?.note); + const db = getDatabase(); + const result = db.prepare(` + INSERT INTO cash_count_records (date, counted_amount, note, created_by, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(date, counted_amount, note, (req as any).user.userId, now()); + const count = db.prepare(` + SELECT c.*, u.name AS created_by_name FROM cash_count_records c LEFT JOIN users u ON u.id = c.created_by WHERE c.id = ? + `).get(result.lastInsertRowid); + res.status(201).json({ count }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to record the cash count' }); + } +}); + +router.get('/monthly', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const month = typeof req.query.month === 'string' && req.query.month ? req.query.month : storeToday().slice(0, 7); + const [from, to] = monthBounds(month); + const db = getDatabase(); + const timezone = tenantTimezone(); + + const [rangeStart] = dayBoundsInTimezone(from, timezone); + const [, rangeEnd] = dayBoundsInTimezone(to, timezone); + const orderLines = cashOrderPaymentLines(db, rangeStart, rangeEnd); + const ordersByDate = new Map(); + for (const line of orderLines) { + // paid_at is UTC; the window above is store-local, so bucket by the + // store-local calendar date, not the UTC date prefix. + const day = localDateInTimezone(parseDbTimestamp(line.payment_time), timezone); + ordersByDate.set(day, roundMoney((ordersByDate.get(day) || 0) + line.amount)); + } + + const expensesByDate = cashExpenseTotalsByDate(db, from, to); + const refundsByDate = cashRefundsByDate(db, rangeStart, rangeEnd, timezone); + const openingByDate = openingFloatsByDate(db, from, to); + const latestCountByDate = latestCountsByDate(db, from, to); + + let totalOpeningFloats = 0; + let totalCashFromOrders = 0; + let totalCashRefunds = 0; + let totalCashExpenses = 0; + + const days = datesInRange(from, to).map((date) => { + const opening = openingByDate.get(date) || 0; + const orders = ordersByDate.get(date) || 0; + const refunds = refundsByDate.get(date) || 0; + const expenses = expensesByDate.get(date) || 0; + const expected = expectedCash(opening, orders, refunds, expenses); + const latestCount = latestCountByDate.get(date) ?? null; + const variance = latestCount !== null ? roundMoney(latestCount - expected) : null; + + totalOpeningFloats = roundMoney(totalOpeningFloats + opening); + totalCashFromOrders = roundMoney(totalCashFromOrders + orders); + totalCashRefunds = roundMoney(totalCashRefunds + refunds); + totalCashExpenses = roundMoney(totalCashExpenses + expenses); + + return { + date, + opening_float: opening, + cash_from_orders: orders, + cash_refunds: refunds, + cash_expenses: expenses, + expected_cash: expected, + latest_count: latestCount, + variance, + }; + }); + + res.json({ + month, + from, + to, + days, + totals: { + total_opening_floats: totalOpeningFloats, + total_cash_from_orders: totalCashFromOrders, + total_cash_refunds: totalCashRefunds, + total_cash_expenses: totalCashExpenses, + net: roundMoney(totalCashFromOrders - totalCashRefunds - totalCashExpenses), + }, + }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to load the monthly cash counter report' }); + } +}); + +export { router as cashCounterRoutes }; diff --git a/main/routes/expenses.ts b/main/routes/expenses.ts new file mode 100644 index 000000000..66a7445e0 --- /dev/null +++ b/main/routes/expenses.ts @@ -0,0 +1,310 @@ +import { Router, Request, Response } from 'express'; +import expressRateLimit from 'express-rate-limit'; +import { getDatabase, now, generateShortId } from '../db'; +import { requireRole } from '../middleware/security'; +import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; +import { + monthBounds, + normalizeBusinessDate, + normalizeNote, + roundMoney, + storeToday, +} from './finance-shared'; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const PAYMENT_METHODS = ['cash', 'card', 'upi'] as const; +type PaymentMethod = typeof PAYMENT_METHODS[number]; + +// Built-ins plus any active custom method (same rule as bill payments in +// main/routes/bills.ts, which resolve customs to their stored name): the +// stored string is the audit trail, so match case-insensitively but keep +// the canonical name. Only 'cash' ever counts as drawer cash downstream, +// and custom names can never collide with it (reserved at creation). +function normalizePaymentMethod(db: ReturnType, value: unknown): string { + if (typeof value !== 'string' || !value.trim()) { + throw Object.assign(new Error('method is required and must be cash, card, upi, or an active custom payment method'), { statusCode: 400 }); + } + const trimmed = value.trim(); + if ((PAYMENT_METHODS as readonly string[]).includes(trimmed)) return trimmed; + const custom = db.prepare('SELECT name FROM payment_methods WHERE lower(name) = lower(?) AND is_active = 1').get(trimmed) as { name: string } | undefined; + if (!custom) { + throw Object.assign(new Error('method is required and must be cash, card, upi, or an active custom payment method'), { statusCode: 400 }); + } + return custom.name; +} + +const router = Router(); +const expenseWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); + +function normalizeCategoryName(value: unknown): string { + if (typeof value !== 'string') throw Object.assign(new Error('Name is required'), { statusCode: 400 }); + const name = value.trim().replace(/\s+/g, ' '); + if (!name || name.length > 60) throw Object.assign(new Error('Name must be between 1 and 60 characters'), { statusCode: 400 }); + return name; +} + +function normalizeAmount(value: unknown): number { + const amount = Number(value); + if (!Number.isFinite(amount) || amount <= 0) { + throw Object.assign(new Error('Amount must be a positive number'), { statusCode: 400 }); + } + return roundMoney(amount); +} + +function requireActiveCategory(db: ReturnType, categoryId: unknown) { + if (typeof categoryId !== 'string' || !categoryId) { + throw Object.assign(new Error('category_id is required'), { statusCode: 400 }); + } + const category = db.prepare('SELECT * FROM expense_categories WHERE id = ? AND deleted_at IS NULL AND is_active = 1').get(categoryId) as any; + if (!category) throw Object.assign(new Error('Expense category not found or inactive'), { statusCode: 404 }); + return category; +} + +function listCategories(includeInactive: boolean) { + const db = getDatabase(); + const rows = db.prepare(` + SELECT + ec.*, + COALESCE(entries.total, 0) AS total_expenses, + COALESCE(payments.total, 0) AS total_payments, + COALESCE(entries.total, 0) - COALESCE(payments.total, 0) AS due + FROM expense_categories ec + LEFT JOIN (SELECT category_id, SUM(amount) AS total FROM expense_entries WHERE voided_at IS NULL GROUP BY category_id) entries + ON entries.category_id = ec.id + LEFT JOIN (SELECT category_id, SUM(amount) AS total FROM expense_due_payments WHERE voided_at IS NULL GROUP BY category_id) payments + ON payments.category_id = ec.id + ${includeInactive ? '' : 'WHERE ec.deleted_at IS NULL AND ec.is_active = 1'} + ORDER BY ec.name COLLATE NOCASE + `).all() as any[]; + // Round due to cents: the raw SUM difference can carry binary float + // residue (e.g. 5e-17), which would read as a nonzero due and block + // category deletion even though nothing is owed. DELETE rechecks via + // categoryDue(), which rounds the same way, so both gates agree. + return rows.map((row) => ({ ...row, is_active: Boolean(row.is_active), due: roundMoney(row.due) })); +} + +function categoryDue(db: ReturnType, categoryId: string): number { + const entries = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE category_id = ? AND voided_at IS NULL').get(categoryId) as { total: number }; + const payments = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE category_id = ? AND voided_at IS NULL').get(categoryId) as { total: number }; + return roundMoney(entries.total - payments.total); +} + +const LEDGER_DATE_COLUMN = { + expense_entries: 'expense_date', + expense_due_payments: 'payment_date', +} as const; + +function listLedger(table: 'expense_entries' | 'expense_due_payments', query: Request['query']) { + const db = getDatabase(); + const dateColumn = LEDGER_DATE_COLUMN[table]; + let sql = ` + SELECT t.*, t.${dateColumn} AS date, ec.name AS category_name, u.name AS created_by_name + FROM ${table} t + JOIN expense_categories ec ON ec.id = t.category_id + LEFT JOIN users u ON u.id = t.created_by + WHERE 1 = 1 AND t.voided_at IS NULL + `; + const params: any[] = []; + if (typeof query.category_id === 'string' && query.category_id) { + sql += ' AND t.category_id = ?'; + params.push(query.category_id); + } + // `date` is an exact-day convenience filter; `from`/`to` give an inclusive + // range. Both filter on the business date column, not created_at. + if (typeof query.date === 'string' && DATE_PATTERN.test(query.date)) { + sql += ` AND t.${dateColumn} = ?`; + params.push(query.date); + } + if (typeof query.from === 'string' && DATE_PATTERN.test(query.from)) { + sql += ` AND t.${dateColumn} >= ?`; + params.push(query.from); + } + if (typeof query.to === 'string' && DATE_PATTERN.test(query.to)) { + sql += ` AND t.${dateColumn} <= ?`; + params.push(query.to); + } + sql += ` ORDER BY t.${dateColumn} DESC, t.created_at DESC, t.id DESC LIMIT ? OFFSET ?`; + const limit = Math.min(Math.max(Number(query.limit) || 100, 1), 500); + const offset = Math.max(Number(query.offset) || 0, 0); + params.push(limit, offset); + return db.prepare(sql).all(...params); +} + +router.get('/categories', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + const includeInactive = req.query.include_inactive === 'true' && hasRole((req as any).user.role, ROLE_ACCESS.ownerManager); + res.json({ categories: listCategories(includeInactive) }); +}); + +router.post('/categories', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: Response) => { + try { + const name = normalizeCategoryName(req.body?.name); + const db = getDatabase(); + const id = generateShortId('expense_categories'); + db.prepare(` + INSERT INTO expense_categories (id, name, is_active, created_at, updated_at, created_by) + VALUES (?, ?, 1, ?, ?, ?) + `).run(id, name, now(), now(), (req as any).user.userId); + res.status(201).json({ category: listCategories(true).find((row) => row.id === id) }); + } catch (error: any) { + const duplicate = String(error.message || '').includes('UNIQUE constraint'); + res.status(duplicate ? 409 : error.statusCode || 500).json({ error: duplicate ? 'An expense category with this name already exists' : error.message || 'Unable to add category' }); + } +}); + +router.delete('/categories/:id', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: Response) => { + const db = getDatabase(); + const categoryId = String(req.params.id); + const category = db.prepare('SELECT * FROM expense_categories WHERE id = ? AND deleted_at IS NULL').get(categoryId); + if (!category) return res.status(404).json({ error: 'Expense category not found' }); + const due = categoryDue(db, categoryId); + if (due !== 0) { + return res.status(400).json({ error: `Category has an outstanding due balance of ${due}. Settle it before deleting.`, due }); + } + db.prepare('UPDATE expense_categories SET deleted_at = ?, is_active = 0, updated_at = ? WHERE id = ?').run(now(), now(), categoryId); + res.json({ success: true }); +}); + +router.get('/entries', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + res.json({ entries: listLedger('expense_entries', req.query) }); +}); + +router.post('/entries', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const db = getDatabase(); + const category = requireActiveCategory(db, req.body?.category_id); + const amount = normalizeAmount(req.body?.amount); + const note = normalizeNote(req.body?.note); + const date = normalizeBusinessDate(req.body?.date); + const result = db.prepare(` + INSERT INTO expense_entries (category_id, amount, note, expense_date, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(category.id, amount, note, date, (req as any).user.userId, now()); + const entry = db.prepare(` + SELECT t.*, t.expense_date AS date, ec.name AS category_name, u.name AS created_by_name + FROM expense_entries t + JOIN expense_categories ec ON ec.id = t.category_id + LEFT JOIN users u ON u.id = t.created_by + WHERE t.id = ? + `).get(result.lastInsertRowid); + res.status(201).json({ entry }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to add expense' }); + } +}); + +router.get('/payments', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + res.json({ payments: listLedger('expense_due_payments', req.query) }); +}); + +// Typo correction without rewriting history: stamping voided_at drops the row +// from every due, total, and ledger read below, while the row itself stays as +// the audit trail. Staff re-enter the correct figure as a new row. Counts are +// excluded on purpose: a wrong count is already superseded by appending a new +// one, since variance always compares the latest count. +function voidLedgerRow(table: 'expense_entries' | 'expense_due_payments', id: string) { + const db = getDatabase(); + const result = db.prepare(`UPDATE ${table} SET voided_at = ? WHERE id = ? AND voided_at IS NULL`).run(now(), id); + if (result.changes === 0) return null; + return db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id); +} + +router.post('/entries/:id/void', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: Response) => { + const entry = voidLedgerRow('expense_entries', String(req.params.id)); + if (!entry) return res.status(404).json({ error: 'Expense entry not found or already voided' }); + res.json({ entry }); +}); + +router.post('/payments/:id/void', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.ownerManager), (req: Request, res: Response) => { + const payment = voidLedgerRow('expense_due_payments', String(req.params.id)); + if (!payment) return res.status(404).json({ error: 'Due payment not found or already voided' }); + res.json({ payment }); +}); + +router.post('/payments', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const db = getDatabase(); + const category = requireActiveCategory(db, req.body?.category_id); + const amount = normalizeAmount(req.body?.amount); + const note = normalizeNote(req.body?.note); + const date = normalizeBusinessDate(req.body?.date); + const method = normalizePaymentMethod(db, req.body?.method); + // A payment may legally exceed the category's current due (e.g. prepaying + // a vendor) — this is allowed on purpose, not clamped or rejected. + const result = db.prepare(` + INSERT INTO expense_due_payments (category_id, amount, note, payment_date, method, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(category.id, amount, note, date, method, (req as any).user.userId, now()); + const payment = db.prepare(` + SELECT t.*, t.payment_date AS date, ec.name AS category_name, u.name AS created_by_name + FROM expense_due_payments t + JOIN expense_categories ec ON ec.id = t.category_id + LEFT JOIN users u ON u.id = t.created_by + WHERE t.id = ? + `).get(result.lastInsertRowid); + res.status(201).json({ payment }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to record payment' }); + } +}); + +router.get('/summary', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const month = typeof req.query.month === 'string' && req.query.month ? req.query.month : storeToday().slice(0, 7); + const [from, to] = monthBounds(month); + const db = getDatabase(); + + const expenseTotals = new Map((db.prepare( + 'SELECT category_id, COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE voided_at IS NULL AND expense_date >= ? AND expense_date <= ? GROUP BY category_id' + ).all(from, to) as { category_id: string; total: number }[]).map((row) => [row.category_id, row.total])); + + const paymentTotals = new Map; custom: Record }>(); + for (const row of db.prepare( + 'SELECT category_id, method, COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE voided_at IS NULL AND payment_date >= ? AND payment_date <= ? GROUP BY category_id, method' + ).all(from, to) as { category_id: string; method: string | null; total: number }[]) { + let bucket = paymentTotals.get(row.category_id); + if (!bucket) { + bucket = { total: 0, byMethod: { cash: 0, card: 0, upi: 0 }, custom: {} }; + paymentTotals.set(row.category_id, bucket); + } + bucket.total = roundMoney(bucket.total + row.total); + if (row.method && PAYMENT_METHODS.includes(row.method as PaymentMethod)) { + bucket.byMethod[row.method as PaymentMethod] = row.total; + } else if (row.method) { + bucket.custom[row.method] = roundMoney((bucket.custom[row.method] || 0) + row.total); + } + } + + const categories = listCategories(false).map((category) => { + const payments = paymentTotals.get(category.id) ?? { total: 0, byMethod: { cash: 0, card: 0, upi: 0 }, custom: {} }; + return { + category_id: category.id, + category_name: category.name, + due: category.due, + total_expenses: roundMoney(expenseTotals.get(category.id) ?? 0), + total_payments: payments.total, + payments_by_method: payments.byMethod, + custom_payments: payments.custom, + }; + }); + + const overallCustom: Record = {}; + const overall = categories.reduce((acc, category) => { + acc.total_expenses = roundMoney(acc.total_expenses + category.total_expenses); + acc.total_payments = roundMoney(acc.total_payments + category.total_payments); + acc.payments_by_method.cash = roundMoney(acc.payments_by_method.cash + category.payments_by_method.cash); + acc.payments_by_method.card = roundMoney(acc.payments_by_method.card + category.payments_by_method.card); + acc.payments_by_method.upi = roundMoney(acc.payments_by_method.upi + category.payments_by_method.upi); + for (const [method, total] of Object.entries(category.custom_payments)) { + overallCustom[method] = roundMoney((overallCustom[method] || 0) + total); + } + return acc; + }, { total_expenses: 0, total_payments: 0, payments_by_method: { cash: 0, card: 0, upi: 0 } }); + + res.json({ month, from, to, categories, overall: { ...overall, custom_payments: overallCustom } }); + } catch (error: any) { + res.status(error.statusCode || 500).json({ error: error.message || 'Unable to load the monthly expense summary' }); + } +}); + +export { router as expenseRoutes }; diff --git a/main/routes/finance-shared.ts b/main/routes/finance-shared.ts new file mode 100644 index 000000000..ef486c711 --- /dev/null +++ b/main/routes/finance-shared.ts @@ -0,0 +1,70 @@ +import { getSettingValue, localDateInTimezone } from '../db'; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_PATTERN = /^\d{4}-\d{2}$/; + +function httpError(message: string, statusCode: number) { + return Object.assign(new Error(message), { statusCode }); +} + +/** + * Store timezone for day boundaries. Reports and the Z day-close read the + * same setting; business dates must agree with them, not with UTC. + */ +export function tenantTimezone(): string { + return getSettingValue('timezone') || 'Asia/Kolkata'; +} + +/** Store-local calendar date — the only "today" a business date may reference. */ +export function storeToday(): string { + return localDateInTimezone(new Date(), tenantTimezone()); +} + +/** + * Business date a record is FOR: defaults to today, may be backdated, never + * postdated. Distinct from created_at, which always stamps the real moment + * of recording and is never client-supplied. + */ +export function normalizeBusinessDate(value: unknown): string { + if (value === undefined || value === null || value === '') return storeToday(); + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + throw httpError('date must be in YYYY-MM-DD format', 400); + } + const [year, month, day] = value.split('-').map(Number); + const roundTrip = new Date(Date.UTC(year, month - 1, day)); + if ( + roundTrip.getUTCFullYear() !== year || + roundTrip.getUTCMonth() + 1 !== month || + roundTrip.getUTCDate() !== day + ) { + throw httpError('date is not a real calendar date', 400); + } + if (value > storeToday()) { + throw httpError('date cannot be in the future', 400); + } + return value; +} + +/** [firstDay, lastDay] of a `YYYY-MM` month, both inclusive `YYYY-MM-DD` strings. */ +export function monthBounds(month: string): [string, string] { + if (!MONTH_PATTERN.test(month)) { + throw httpError('month must be in YYYY-MM format', 400); + } + const [year, mon] = month.split('-').map(Number); + if (mon < 1 || mon > 12) { + throw httpError('month must be in YYYY-MM format', 400); + } + const lastDay = new Date(Date.UTC(year, mon, 0)).getUTCDate(); + return [`${month}-01`, `${month}-${String(lastDay).padStart(2, '0')}`]; +} + +export function roundMoney(value: number): number { + return Math.round(value * 100) / 100; +} + +export function normalizeNote(value: unknown): string | null { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed.slice(0, 500) : null; +} diff --git a/main/routes/index.ts b/main/routes/index.ts index c1aba008c..c73e23215 100644 --- a/main/routes/index.ts +++ b/main/routes/index.ts @@ -17,6 +17,8 @@ import { customerRoutes, parseCustomer, getWalletBalance } from './customers'; import { staffRoutes } from './staff'; import { settingsRoutes } from './settings'; import { paymentMethodRoutes } from './payment-methods'; +import { expenseRoutes } from './expenses'; +import { cashCounterRoutes } from './cash-counter'; import { reportRoutes } from './reports'; import { kdsRoutes } from './kds'; import { kdsInfoRoutes } from './kds-info'; @@ -91,6 +93,8 @@ export function registerRoutes(app: Express): void { app.use('/api/users', staffRoutes); // same router, dual-mounted app.use('/api/settings', settingsRoutes); app.use('/api/payment-methods', paymentMethodRoutes); + app.use('/api/expenses', expenseRoutes); + app.use('/api/cash-counter', cashCounterRoutes); app.use('/api/reports', reportRoutes); app.use('/api/kds', kdsRoutes); app.use('/api/kds-info', kdsInfoRoutes); diff --git a/package.json b/package.json index 2389c0b11..cf7cfc91d 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,9 @@ "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:phase7-setup-i18n && 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:issue-278-fail-closed-db && 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:printing-settings && 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:thermal-capabilities && bash tests/run-test.sh npm run test:raster && 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:cash-closures && 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:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && 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:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && 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 && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626", + "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:phase7-setup-i18n && 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:expenses && bash tests/run-test.sh npm run test:cash-counter && 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:issue-278-fail-closed-db && 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:printing-settings && 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:thermal-capabilities && bash tests/run-test.sh npm run test:raster && 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:cash-closures && 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:reports-daily-stats-table-turn && bash tests/run-test.sh npm run test:timezone-report-boundaries && 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:migration-v80-cash-drawer-pulse && bash tests/run-test.sh npm run test:migration-v81-cash-closures && 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 && bash tests/run-test.sh npm run test:theme-mode-settings && bash tests/run-test.sh npm run test:theme-fouc-script && bash tests/run-test.sh npm run test:ui-regressions-621-623-626", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:runtime-recovery && npm run test:backend-health && npm run test:url-allowlist && npm run test:static-routes", - "test: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", + "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", "test:phase2": "ts-node --transpile-only -P tests/tsconfig.json tests/auth-state-recovery.test.ts && node tests/run-electron-node-test.cjs tests/login-email-normalization.test.ts && node tests/run-electron-node-test.cjs tests/master-pin.test.ts && node tests/run-electron-node-test.cjs tests/manager-pin-verification.test.ts && node tests/run-electron-node-test.cjs tests/jwt-logout-lifecycle.test.ts && node tests/run-electron-node-test.cjs tests/kds-websocket-revalidation.test.ts && node tests/run-electron-node-test.cjs tests/token-revocation-fail-closed.test.ts", "test:url-allowlist": "ts-node --transpile-only -P tests/tsconfig.json tests/url-allowlist.test.ts", "test:startup-cache": "ts-node --transpile-only -P tests/tsconfig.json tests/startup-cache.test.ts", @@ -78,6 +77,8 @@ "test:first-run": "node tests/run-electron-node-test.cjs tests/first-run-setup.test.ts && node tests/run-electron-node-test.cjs tests/setup-failure-recovery.test.ts", "test:security": "node tests/run-electron-node-test.cjs tests/security-hardening.test.ts", "test:staff-authz": "node tests/run-electron-node-test.cjs tests/staff-authz.test.ts", + "test:expenses": "node tests/run-electron-node-test.cjs tests/expenses.test.ts", + "test:cash-counter": "node tests/run-electron-node-test.cjs tests/cash-counter.test.ts", "test:orders-authz": "node tests/run-electron-node-test.cjs tests/orders-authz.test.ts", "test:authz-phase3": "node tests/run-electron-node-test.cjs tests/authz-matrix-phase3.test.ts", "test:customer-auth": "node tests/run-electron-node-test.cjs tests/customer-auth.test.ts", diff --git a/shared/role-permissions.ts b/shared/role-permissions.ts index 3efe203b0..b4de93c71 100644 --- a/shared/role-permissions.ts +++ b/shared/role-permissions.ts @@ -60,7 +60,8 @@ export type PermissionArea = | 'settings' | 'integrations' | 'system' - | 'support'; + | 'support' + | 'expenses'; export type PermissionCapability = { id: string; @@ -118,6 +119,9 @@ export const PERMISSION_CAPABILITIES = [ { id: 'databaseTools', area: 'system', labelKey: 'databaseTools', allowedRoles: ROLE_ACCESS.owner }, { id: 'serverApp', area: 'orders', labelKey: 'serverApp', allowedRoles: ROLE_ACCESS.serverApp }, { id: 'support', area: 'support', labelKey: 'support', allowedRoles: ROLE_ACCESS.allStaff }, + { id: 'expenseCategoriesManage', area: 'expenses', labelKey: 'expenseCategoriesManage', allowedRoles: ROLE_ACCESS.ownerManager }, + { id: 'expenseEntriesRecord', area: 'expenses', labelKey: 'expenseEntriesRecord', allowedRoles: ROLE_ACCESS.allStaff }, + { id: 'cashCounterRecord', area: 'expenses', labelKey: 'cashCounterRecord', allowedRoles: ROLE_ACCESS.allStaff }, ] as const satisfies readonly PermissionCapability[]; export type PermissionCapabilityId = typeof PERMISSION_CAPABILITIES[number]['id']; diff --git a/tests/cash-counter.test.ts b/tests/cash-counter.test.ts new file mode 100644 index 000000000..470006897 --- /dev/null +++ b/tests/cash-counter.test.ts @@ -0,0 +1,250 @@ +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-cash-counter-')); +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 bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { + initTestDb, createApp, startServer, seedOwnerUser, seedManagerUser, seedCategory, seedProduct, + api, assert, assertEqual, getResults, closeDatabase, now, rawStatus, +} = require('./helpers/test-setup'); +const { orderRoutes } = require('../main/routes/orders'); +const { billRoutes } = require('../main/routes/bills'); +const { utcTodayDate } = require('../main/db'); + +function seedUserWithRole(db: any, role: string): { userId: string; authHeader: Record } { + const { getJWTSecret } = require('../main/routes/auth'); + const userId = `${role}-test-001`; + const passwordHash = bcrypt.hashSync('testpass123', 10); + db.prepare( + `INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run(userId, `Test ${role}`, `${role}@test.local`, passwordHash, role, 1, now(), now()); + const token = jwt.sign({ userId, email: `${role}@test.local`, role }, getJWTSecret(), { expiresIn: '1h' }); + return { userId, authHeader: { Authorization: `Bearer ${token}` } }; +} + +async function payFullBill(baseUrl: string, billId: number, method: string, amount: number, authHeader: Record) { + return api(baseUrl, `/api/bills/${billId}/payments`, { method: 'POST', body: { payments: [{ method, amount }] }, headers: authHeader }); +} + +async function main() { + const db = initTestDb(); + const { authHeader: ownerAuth, userId: ownerId } = seedOwnerUser(db); + // Deterministic day windows regardless of host clock (same convention as + // the Z day-close tests); the Asia/Kolkata case below opts back in briefly. + db.prepare("UPDATE settings SET value = 'UTC' WHERE key = 'timezone'").run(); + if ((db.prepare("SELECT COUNT(*) as c FROM settings WHERE key = 'timezone'").get() as any).c === 0) { + db.prepare("INSERT INTO settings (key, value) VALUES ('timezone', 'UTC')").run(); + } + const mgr = seedManagerUser(db); + const cashier = seedUserWithRole(db, 'cashier'); + const server = seedUserWithRole(db, 'server'); + const chef = seedUserWithRole(db, 'chef'); + + seedCategory(db, 'cc-cat', 'Cash Counter Menu'); + seedProduct(db, 'cc-cash-item', 'cc-cat', 'Cash Item', 100); + seedProduct(db, 'cc-card-item', 'cc-cat', 'Card Item', 50); + + const app = createApp({ '/api/orders': orderRoutes, '/api/bills': billRoutes }); + const { registerRoutes } = require('../main/routes/index'); + registerRoutes(app); + const { baseUrl, server: httpServer } = await startServer(app); + + try { + const today = utcTodayDate(); + + // ── Seed one cash-paid bill and one card-paid bill ────────────────────── + const cashOrderRes = await api(baseUrl, '/api/orders', { method: 'POST', body: { type: 'dine_in', guest_count: 1, items: [{ product_id: 'cc-cash-item', quantity: 1 }] }, headers: ownerAuth }); + const cashBillRes = await api(baseUrl, '/api/bills/generate', { method: 'POST', body: { order_id: cashOrderRes.data.order.id }, headers: ownerAuth }); + const cashPayRes = await payFullBill(baseUrl, cashBillRes.data.bill.id, 'cash', cashBillRes.data.bill.total, ownerAuth); + assertEqual(cashPayRes.status, 200, 'cash bill payment recorded'); + + const cardOrderRes = await api(baseUrl, '/api/orders', { method: 'POST', body: { type: 'dine_in', guest_count: 1, items: [{ product_id: 'cc-card-item', quantity: 1 }] }, headers: ownerAuth }); + const cardBillRes = await api(baseUrl, '/api/bills/generate', { method: 'POST', body: { order_id: cardOrderRes.data.order.id }, headers: ownerAuth }); + const cardPayRes = await payFullBill(baseUrl, cardBillRes.data.bill.id, 'card', cardBillRes.data.bill.total, ownerAuth); + assertEqual(cardPayRes.status, 200, 'card bill payment recorded'); + + // ── Seed one cash expense payment (needs an expense category) ────────── + const createCategory = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'Kirana' }, headers: ownerAuth }); + const categoryId = createCategory.data.category.id; + await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: categoryId, amount: 200 }, headers: ownerAuth }); + const cashExpensePay = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: categoryId, amount: 30, method: 'cash' }, headers: ownerAuth }); + assertEqual(cashExpensePay.status, 201, 'cash expense payment recorded'); + await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: categoryId, amount: 15, method: 'card' }, headers: ownerAuth }); + + // ── Daily view before any opening float / count ───────────────────────── + const dailyBefore = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyBefore.status, 200, 'daily cash counter loads'); + assertEqual(dailyBefore.data.opening_float, null, 'no opening float set yet'); + assertEqual(dailyBefore.data.cash_from_orders.total, cashBillRes.data.bill.total, 'cash-from-orders totals only the cash-paid bill'); + assert(!dailyBefore.data.cash_from_orders.payments.some((p: any) => p.bill_id === cardBillRes.data.bill.id), 'the card-paid bill is excluded from cash-from-orders'); + assertEqual(dailyBefore.data.cash_expenses.total, 30, 'cash-expenses totals only the cash-method expense payment (card payment excluded)'); + assertEqual(dailyBefore.data.expected_cash, Math.round((0 + cashBillRes.data.bill.total - 30) * 100) / 100, 'expected_cash = opening float (0) + cash from orders - cash expenses'); + assertEqual(dailyBefore.data.counts.length, 0, 'no counts logged yet'); + assertEqual(dailyBefore.data.latest_count, null, 'no latest count yet'); + assertEqual(dailyBefore.data.variance, null, 'no variance without a count'); + + // ── Opening float ───────────────────────────────────────────────────── + const setFloat = await api(baseUrl, '/api/cash-counter/opening-float', { method: 'POST', body: { date: today, amount: 20 }, headers: ownerAuth }); + assertEqual(setFloat.status, 201, 'owner sets the opening float'); + const duplicateFloat = await api(baseUrl, '/api/cash-counter/opening-float', { method: 'POST', body: { date: today, amount: 999 }, headers: mgr.authHeader }); + assertEqual(duplicateFloat.status, 409, 'a second opening float for the same date is rejected'); + const negativeFloat = await api(baseUrl, '/api/cash-counter/opening-float', { method: 'POST', body: { date: '2026-01-01', amount: -5 }, headers: ownerAuth }); + assertEqual(negativeFloat.status, 400, 'a negative opening float is rejected'); + + const dailyAfterFloat = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyAfterFloat.data.opening_float.amount, 20, 'opening float now reflected in the daily view'); + const expectedAfterFloat = Math.round((20 + cashBillRes.data.bill.total - 30) * 100) / 100; + assertEqual(dailyAfterFloat.data.expected_cash, expectedAfterFloat, 'expected_cash includes the opening float'); + + // ── Counts: append-only, latest wins for variance ─────────────────────── + const firstCount = await api(baseUrl, '/api/cash-counter/count', { method: 'POST', body: { date: today, counted_amount: expectedAfterFloat + 5 }, headers: cashier.authHeader }); + assertEqual(firstCount.status, 201, 'cashier can record a cash count'); + const secondCount = await api(baseUrl, '/api/cash-counter/count', { method: 'POST', body: { date: today, counted_amount: expectedAfterFloat - 2 }, headers: server.authHeader }); + assertEqual(secondCount.status, 201, 'server can record a second cash count the same day'); + + const dailyAfterCounts = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyAfterCounts.data.counts.length, 2, 'both counts are preserved (append-only)'); + assertEqual(dailyAfterCounts.data.latest_count.counted_amount, expectedAfterFloat - 2, 'latest_count is the most recently recorded count'); + assertEqual(dailyAfterCounts.data.variance, -2, 'variance compares the latest count to expected_cash, never mutating it'); + assertEqual(dailyAfterCounts.data.expected_cash, expectedAfterFloat, 'expected_cash is unchanged by recording counts'); + + const negativeCount = await api(baseUrl, '/api/cash-counter/count', { method: 'POST', body: { date: today, counted_amount: -1 }, headers: ownerAuth }); + assertEqual(negativeCount.status, 400, 'a negative counted_amount is rejected'); + + // ── Every role can log a float (distinct dates) and a count ───────────── + const roles = [ + { label: 'owner', auth: ownerAuth, date: '2026-01-02' }, + { label: 'manager', auth: mgr.authHeader, date: '2026-01-03' }, + { label: 'cashier', auth: cashier.authHeader, date: '2026-01-04' }, + { label: 'server', auth: server.authHeader, date: '2026-01-05' }, + { label: 'chef', auth: chef.authHeader, date: '2026-01-06' }, + ]; + for (const role of roles) { + const floatRes = await api(baseUrl, '/api/cash-counter/opening-float', { method: 'POST', body: { date: role.date, amount: 10 }, headers: role.auth }); + assertEqual(floatRes.status, 201, `${role.label} can set an opening float`); + const countRes = await api(baseUrl, '/api/cash-counter/count', { method: 'POST', body: { date: role.date, counted_amount: 10 }, headers: role.auth }); + assertEqual(countRes.status, 201, `${role.label} can record a cash count`); + } + + // ── No mutation routes exist for either resource ──────────────────────── + const floatDeleteStatus = await rawStatus(baseUrl, '/api/cash-counter/opening-float', 'DELETE', ownerAuth); + assertEqual(floatDeleteStatus, 404, 'no DELETE route exists for opening floats'); + const floatPutStatus = await rawStatus(baseUrl, '/api/cash-counter/opening-float', 'PUT', ownerAuth); + assertEqual(floatPutStatus, 404, 'no PUT route exists for opening floats'); + const countDeleteStatus = await rawStatus(baseUrl, '/api/cash-counter/count', 'DELETE', ownerAuth); + assertEqual(countDeleteStatus, 404, 'no DELETE route exists for cash counts'); + const countPutStatus = await rawStatus(baseUrl, '/api/cash-counter/count', 'PUT', ownerAuth); + assertEqual(countPutStatus, 404, 'no PUT route exists for cash counts'); + + // ── Validation ─────────────────────────────────────────────────────── + const futureDate = new Date(new Date(`${today}T00:00:00Z`).getTime() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const futureFloat = await api(baseUrl, '/api/cash-counter/opening-float', { method: 'POST', body: { date: futureDate, amount: 5 }, headers: ownerAuth }); + assertEqual(futureFloat.status, 400, 'a postdated opening float is rejected'); + const malformedMonth = await api(baseUrl, '/api/cash-counter/monthly?month=2026-13', { headers: ownerAuth }); + assertEqual(malformedMonth.status, 400, 'an invalid month is rejected'); + + // ── Monthly view ───────────────────────────────────────────────────── + const thisMonth = today.slice(0, 7); + const monthly = await api(baseUrl, `/api/cash-counter/monthly?month=${thisMonth}`, { headers: ownerAuth }); + assertEqual(monthly.status, 200, 'monthly cash counter report loads'); + const todayRow = monthly.data.days.find((d: any) => d.date === today); + assertEqual(todayRow.opening_float, 20, 'monthly per-day row matches the daily opening float'); + assertEqual(todayRow.cash_from_orders, cashBillRes.data.bill.total, 'monthly per-day row matches the daily cash-from-orders total'); + assertEqual(todayRow.cash_expenses, 30, 'monthly per-day row matches the daily cash-expenses total'); + assertEqual(todayRow.expected_cash, expectedAfterFloat, 'monthly per-day expected_cash matches the daily figure'); + assertEqual(todayRow.variance, -2, 'monthly per-day variance matches the daily figure'); + + const inactiveDay = monthly.data.days.find((d: any) => d.date === `${thisMonth}-01` && d.date !== today); + if (inactiveDay) { + assertEqual(inactiveDay.opening_float, 0, 'a day with no activity is zero-filled for opening_float'); + assertEqual(inactiveDay.cash_from_orders, 0, 'a day with no activity is zero-filled for cash_from_orders'); + assertEqual(inactiveDay.cash_expenses, 0, 'a day with no activity is zero-filled for cash_expenses'); + assertEqual(inactiveDay.expected_cash, 0, 'a day with no activity has zero expected_cash'); + assertEqual(inactiveDay.latest_count, null, 'a day with no count logged has a null latest_count'); + assertEqual(inactiveDay.variance, null, 'a day with no count logged has a null variance'); + } + + const daysSum = monthly.data.days.reduce((acc: any, d: any) => ({ + cash_from_orders: Math.round((acc.cash_from_orders + d.cash_from_orders) * 100) / 100, + cash_expenses: Math.round((acc.cash_expenses + d.cash_expenses) * 100) / 100, + }), { cash_from_orders: 0, cash_expenses: 0 }); + assertEqual(monthly.data.totals.total_cash_from_orders, daysSum.cash_from_orders, 'month totals.total_cash_from_orders equals the sum of daily rows'); + assertEqual(monthly.data.totals.total_cash_expenses, daysSum.cash_expenses, 'month totals.total_cash_expenses equals the sum of daily rows'); + assertEqual(monthly.data.totals.net, Math.round((daysSum.cash_from_orders - daysSum.cash_expenses) * 100) / 100, 'month totals.net = total cash from orders - total cash expenses'); + + // ── Cash refunds leave the drawer: expected_cash subtracts them ───────── + db.prepare(` + INSERT INTO refunds (bill_id, amount_cents, method, reason, approved_by, created_by, created_at) + VALUES (?, 1000, 'cash', 'test cash refund', ?, ?, ?) + `).run(cashBillRes.data.bill.id, ownerId, ownerId, now()); + db.prepare(` + INSERT INTO refunds (bill_id, amount_cents, method, reason, approved_by, created_by, created_at) + VALUES (?, 500, 'card', 'test card refund', ?, ?, ?) + `).run(cardBillRes.data.bill.id, ownerId, ownerId, now()); + + const dailyAfterRefund = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyAfterRefund.data.cash_refunds.total, 10, 'cash_refunds totals only cash-method refunds'); + assertEqual(dailyAfterRefund.data.expected_cash, Math.round((expectedAfterFloat - 10) * 100) / 100, 'expected_cash subtracts cash refunds'); + + const monthlyAfterRefund = await api(baseUrl, `/api/cash-counter/monthly?month=${thisMonth}`, { headers: ownerAuth }); + const todayRowAfter = monthlyAfterRefund.data.days.find((d: any) => d.date === today); + assertEqual(todayRowAfter.cash_refunds, 10, 'monthly per-day row reports cash refunds'); + assertEqual(todayRowAfter.expected_cash, dailyAfterRefund.data.expected_cash, 'monthly per-day expected_cash matches the daily figure after refunds'); + assertEqual(monthlyAfterRefund.data.totals.total_cash_refunds, 10, 'month totals include cash refunds'); + assertEqual(monthlyAfterRefund.data.totals.net, Math.round((monthlyAfterRefund.data.totals.total_cash_from_orders - 10 - monthlyAfterRefund.data.totals.total_cash_expenses) * 100) / 100, 'month totals.net subtracts cash refunds'); + + // ── Custom-method expense payments never touch drawer cash ───────────── + db.prepare(`INSERT INTO payment_methods (name, is_active, sort_order, created_at, updated_at) VALUES ('Cheque', 1, 10, ?, ?)`).run(now(), now()); + const chequePay = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: categoryId, amount: 25, method: 'Cheque' }, headers: ownerAuth }); + assertEqual(chequePay.status, 201, 'a custom-method expense payment is accepted'); + const dailyAfterCheque = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyAfterCheque.data.cash_expenses.total, 30, 'a non-cash expense payment leaves cash-expenses untouched'); + + // ── Voiding the float restores a clean slate for the same date ───────── + const floatId = dailyAfterCheque.data.opening_float.id; + const voidFloatForbidden = await api(baseUrl, `/api/cash-counter/opening-float/${floatId}/void`, { method: 'POST', headers: cashier.authHeader }); + assertEqual(voidFloatForbidden.status, 403, 'cashier cannot void the opening float'); + const voidFloat = await api(baseUrl, `/api/cash-counter/opening-float/${floatId}/void`, { method: 'POST', headers: ownerAuth }); + assertEqual(voidFloat.status, 200, 'owner voids a mistyped opening float'); + const dailyAfterVoid = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyAfterVoid.data.opening_float, null, 'a voided float no longer feeds the daily view'); + const doubleVoidFloat = await api(baseUrl, `/api/cash-counter/opening-float/${floatId}/void`, { method: 'POST', headers: ownerAuth }); + assertEqual(doubleVoidFloat.status, 404, 'voiding the same float twice is rejected'); + const refloat = await api(baseUrl, '/api/cash-counter/opening-float', { method: 'POST', body: { date: today, amount: 25 }, headers: ownerAuth }); + assertEqual(refloat.status, 201, 'a corrected float can be set for the same date after voiding'); + const dailyAfterRefloat = await api(baseUrl, `/api/cash-counter/daily?date=${today}`, { headers: ownerAuth }); + assertEqual(dailyAfterRefloat.data.opening_float.amount, 25, 'the corrected float feeds the daily view'); + + // ── Store-timezone alignment uses fixed timestamps, host-clock free ───── + db.prepare("UPDATE settings SET value = 'Asia/Kolkata' WHERE key = 'timezone'").run(); + const tzOrderRes = await api(baseUrl, '/api/orders', { method: 'POST', body: { type: 'dine_in', guest_count: 1, items: [{ product_id: 'cc-cash-item', quantity: 1 }] }, headers: ownerAuth }); + const tzBillRes = await api(baseUrl, '/api/bills/generate', { method: 'POST', body: { order_id: tzOrderRes.data.order.id }, headers: ownerAuth }); + await payFullBill(baseUrl, tzBillRes.data.bill.id, 'cash', tzBillRes.data.bill.total, ownerAuth); + // 2026-01-10 19:00 UTC is 2026-01-11 00:30 IST: paid on the UTC 10th, + // settled on the store-local 11th. + db.prepare(`UPDATE bills SET paid_at = '2026-01-10 19:00:00' WHERE id = ?`).run(tzBillRes.data.bill.id); + const istDay = await api(baseUrl, '/api/cash-counter/daily?date=2026-01-11', { headers: ownerAuth }); + assert(istDay.data.cash_from_orders.payments.some((p: any) => p.bill_id === tzBillRes.data.bill.id), 'a late-UTC bill lands on the store-local day'); + const utcDay = await api(baseUrl, '/api/cash-counter/daily?date=2026-01-10', { headers: ownerAuth }); + assert(!utcDay.data.cash_from_orders.payments.some((p: any) => p.bill_id === tzBillRes.data.bill.id), 'the same bill does not land on the UTC day'); + db.prepare("UPDATE settings SET value = 'UTC' WHERE key = 'timezone'").run(); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + closeDatabase(); + } + + const results = getResults(); + console.log(`\n${results.passed}/${results.total} passed`); + if (results.failed) process.exit(1); +} + +main().catch((error: unknown) => { console.error(error); process.exit(1); }); diff --git a/tests/expenses.test.ts b/tests/expenses.test.ts new file mode 100644 index 000000000..9aa3cfa93 --- /dev/null +++ b/tests/expenses.test.ts @@ -0,0 +1,276 @@ +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-expenses-')); +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 bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { + initTestDb, createApp, startServer, seedOwnerUser, seedManagerUser, + api, assert, assertEqual, getResults, closeDatabase, now, rawStatus, +} = require('./helpers/test-setup'); +const { expenseRoutes } = require('../main/routes/expenses'); +const { utcTodayDate } = require('../main/db'); + +function seedUserWithRole(db: any, role: string): { userId: string; authHeader: Record } { + const { getJWTSecret } = require('../main/routes/auth'); + const userId = `${role}-test-001`; + const passwordHash = bcrypt.hashSync('testpass123', 10); + db.prepare( + `INSERT OR IGNORE INTO users (id, name, email, password, role, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run(userId, `Test ${role}`, `${role}@test.local`, passwordHash, role, 1, now(), now()); + const token = jwt.sign({ userId, email: `${role}@test.local`, role }, getJWTSecret(), { expiresIn: '1h' }); + return { userId, authHeader: { Authorization: `Bearer ${token}` } }; +} + +async function main() { + const db = initTestDb(); + const { authHeader: ownerAuth } = seedOwnerUser(db); + // Store-timezone validation compares against the store day: pin it to UTC + // so UTC-based expectations stay deterministic on any host clock + // (same convention as the cash-counter and Z day-close tests). + db.prepare("UPDATE settings SET value = 'UTC' WHERE key = 'timezone'").run(); + if ((db.prepare("SELECT COUNT(*) as c FROM settings WHERE key = 'timezone'").get() as any).c === 0) { + db.prepare("INSERT INTO settings (key, value) VALUES ('timezone', 'UTC')").run(); + } + const mgr = seedManagerUser(db); + const cashier = seedUserWithRole(db, 'cashier'); + const server = seedUserWithRole(db, 'server'); + const chef = seedUserWithRole(db, 'chef'); + + const app = createApp({ '/api/expenses': expenseRoutes }); + const { baseUrl, server: httpServer } = await startServer(app); + + try { + // ── Category CRUD ────────────────────────────────────────────────────── + const emptyList = await api(baseUrl, '/api/expenses/categories', { headers: ownerAuth }); + assertEqual(emptyList.status, 200, 'fresh install lists expense categories'); + assertEqual(emptyList.data.categories.length, 0, 'fresh install has no expense categories'); + + const cashierCreateAttempt = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'Chicken' }, headers: cashier.authHeader }); + assertEqual(cashierCreateAttempt.status, 403, 'cashier cannot create an expense category'); + + const createChicken = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'Chicken' }, headers: ownerAuth }); + assertEqual(createChicken.status, 201, 'owner creates an expense category'); + const chickenId = createChicken.data.category.id; + + const managerCreateVeg = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'Vegetables' }, headers: mgr.authHeader }); + assertEqual(managerCreateVeg.status, 201, 'manager creates an expense category'); + const vegId = managerCreateVeg.data.category.id; + + const duplicate = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'chicken' }, headers: ownerAuth }); + assertEqual(duplicate.status, 409, 'duplicate category name (case-insensitive) is rejected'); + + const emptyName = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: ' ' }, headers: ownerAuth }); + assertEqual(emptyName.status, 400, 'blank category name is rejected'); + + // ── Entries: every role can record an expense ────────────────────────── + const roles = [ + { label: 'owner', auth: ownerAuth }, + { label: 'manager', auth: mgr.authHeader }, + { label: 'cashier', auth: cashier.authHeader }, + { label: 'server', auth: server.authHeader }, + { label: 'chef', auth: chef.authHeader }, + ]; + for (const role of roles) { + const res = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: chickenId, amount: 100, note: `${role.label} bought chicken` }, headers: role.auth }); + assertEqual(res.status, 201, `${role.label} can record an expense entry`); + } + + const badCategoryEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: 'does-not-exist', amount: 10 }, headers: ownerAuth }); + assertEqual(badCategoryEntry.status, 404, 'entry against an unknown category is rejected'); + + const zeroAmountEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: chickenId, amount: 0 }, headers: ownerAuth }); + assertEqual(zeroAmountEntry.status, 400, 'zero-amount entry is rejected'); + + const negativeAmountEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: chickenId, amount: -5 }, headers: ownerAuth }); + assertEqual(negativeAmountEntry.status, 400, 'negative-amount entry is rejected'); + + // No mutate/delete route exists for entries — Express returns its own + // "not found" 404 (no matching route), proving the endpoint is absent + // rather than merely role-blocked. + const entriesDeleteStatus = await rawStatus(baseUrl, '/api/expenses/entries/1', 'DELETE', ownerAuth); + assertEqual(entriesDeleteStatus, 404, 'no DELETE route exists for expense entries'); + const entriesPutStatus = await rawStatus(baseUrl, '/api/expenses/entries/1', 'PUT', ownerAuth); + assertEqual(entriesPutStatus, 404, 'no PUT route exists for expense entries'); + + // ── Entry date: defaults to today, may be backdated, never postdated ─── + // Uses its own category so these entries don't perturb chickenId's due + // arithmetic asserted later. + const createEgg = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'Egg' }, headers: ownerAuth }); + const eggId = createEgg.data.category.id; + + const today = utcTodayDate(); + const yesterday = new Date(new Date(`${today}T00:00:00Z`).getTime() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const tomorrow = new Date(new Date(`${today}T00:00:00Z`).getTime() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + + const defaultDateEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: eggId, amount: 5 }, headers: ownerAuth }); + assertEqual(defaultDateEntry.data.entry.date, today, 'an entry with no date defaults to today (UTC)'); + + const backdatedEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: eggId, amount: 5, date: yesterday }, headers: ownerAuth }); + assertEqual(backdatedEntry.status, 201, 'a backdated entry is accepted'); + assertEqual(backdatedEntry.data.entry.date, yesterday, 'a backdated entry keeps the caller-supplied date'); + + const postdatedEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: eggId, amount: 5, date: tomorrow }, headers: ownerAuth }); + assertEqual(postdatedEntry.status, 400, 'a postdated (future) entry is rejected'); + + const malformedDateEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: eggId, amount: 5, date: '04-09-2026' }, headers: ownerAuth }); + assertEqual(malformedDateEntry.status, 400, 'a non-ISO date format is rejected'); + + const yesterdayFiltered = await api(baseUrl, `/api/expenses/entries?category_id=${eggId}&date=${yesterday}`, { headers: ownerAuth }); + assertEqual(yesterdayFiltered.data.entries.length, 1, 'filtering entries by date returns only that day\'s rows'); + assertEqual(yesterdayFiltered.data.entries[0].date, yesterday, 'the date-filtered entry carries the filtered date'); + + // ── Payments: every role can record a due payment ────────────────────── + for (const role of roles) { + const res = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: chickenId, amount: 50, note: `${role.label} paid vendor`, method: 'cash' }, headers: role.auth }); + assertEqual(res.status, 201, `${role.label} can record a due payment`); + } + + // ── Payment method: required, must be cash/card/upi ───────────────────── + const missingMethod = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: chickenId, amount: 10 }, headers: ownerAuth }); + assertEqual(missingMethod.status, 400, 'a payment with no method is rejected'); + const invalidMethod = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: chickenId, amount: 10, method: 'bitcoin' }, headers: ownerAuth }); + assertEqual(invalidMethod.status, 400, 'a payment with an unrecognized method is rejected'); + + // ── Monthly report: uses its own category so month totals aren't ─────── + // perturbed by chicken/veg/egg activity created earlier in this run. + const createCurd = await api(baseUrl, '/api/expenses/categories', { method: 'POST', body: { name: 'Curd' }, headers: ownerAuth }); + const curdId = createCurd.data.category.id; + const thisMonth = utcTodayDate().slice(0, 7); + + const curdExpense = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: curdId, amount: 300 }, headers: ownerAuth }); + assertEqual(curdExpense.status, 201, 'curd expense recorded for the monthly report'); + + const curdCash = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: curdId, amount: 100, method: 'cash' }, headers: ownerAuth }); + assertEqual(curdCash.data.payment.method, 'cash', 'a cash payment echoes back its method'); + const curdCard = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: curdId, amount: 50, method: 'card' }, headers: ownerAuth }); + assertEqual(curdCard.data.payment.method, 'card', 'a card payment echoes back its method'); + const curdUpi = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: curdId, amount: 25, method: 'upi' }, headers: ownerAuth }); + assertEqual(curdUpi.data.payment.method, 'upi', 'a upi payment echoes back its method'); + + const noMonthSummary = await api(baseUrl, '/api/expenses/summary', { headers: ownerAuth }); + assertEqual(noMonthSummary.status, 200, 'summary with no month param defaults to the current month'); + assertEqual(noMonthSummary.data.month, thisMonth, 'the default summary month is the current UTC month'); + + const summary = await api(baseUrl, `/api/expenses/summary?month=${thisMonth}`, { headers: ownerAuth }); + assertEqual(summary.status, 200, 'monthly summary is fetched'); + const curdSummary = summary.data.categories.find((c: any) => c.category_id === curdId); + assertEqual(curdSummary.total_expenses, 300, 'monthly summary totals this month\'s expenses for the category'); + assertEqual(curdSummary.total_payments, 175, 'monthly summary totals this month\'s payments for the category'); + assertEqual(curdSummary.payments_by_method.cash, 100, 'monthly summary breaks down cash payments'); + assertEqual(curdSummary.payments_by_method.card, 50, 'monthly summary breaks down card payments'); + assertEqual(curdSummary.payments_by_method.upi, 25, 'monthly summary breaks down upi payments'); + assertEqual(curdSummary.due, 125, 'monthly summary also reports the category\'s lifetime due for reference'); + + const recomputedOverall = summary.data.categories.reduce((acc: any, c: any) => ({ + total_expenses: Number((acc.total_expenses + c.total_expenses).toFixed(2)), + total_payments: Number((acc.total_payments + c.total_payments).toFixed(2)), + }), { total_expenses: 0, total_payments: 0 }); + assertEqual(summary.data.overall.total_expenses, recomputedOverall.total_expenses, 'overall total_expenses is the sum across every category'); + assertEqual(summary.data.overall.total_payments, recomputedOverall.total_payments, 'overall total_payments is the sum across every category'); + + const badMonth = await api(baseUrl, '/api/expenses/summary?month=2026-13', { headers: ownerAuth }); + assertEqual(badMonth.status, 400, 'an invalid month is rejected'); + + const paymentsDeleteStatus = await rawStatus(baseUrl, '/api/expenses/payments/1', 'DELETE', ownerAuth); + assertEqual(paymentsDeleteStatus, 404, 'no DELETE route exists for due payments'); + const paymentsPutStatus = await rawStatus(baseUrl, '/api/expenses/payments/1', 'PUT', ownerAuth); + assertEqual(paymentsPutStatus, 404, 'no PUT route exists for due payments'); + + // ── Due computation ────────────────────────────────────────────────── + // Chicken: 5 entries of 100 = 500, 5 payments of 50 = 250 -> due 250 + const afterChicken = await api(baseUrl, '/api/expenses/categories', { headers: ownerAuth }); + const chickenRow = afterChicken.data.categories.find((c: any) => c.id === chickenId); + assertEqual(chickenRow.total_expenses, 500, 'chicken category totals every recorded expense'); + assertEqual(chickenRow.total_payments, 250, 'chicken category totals every recorded payment'); + assertEqual(chickenRow.due, 250, 'chicken due = total expenses - total payments'); + + // Vegetables: untouched category has zero due and does not leak Chicken's ledger. + const vegRow = afterChicken.data.categories.find((c: any) => c.id === vegId); + assertEqual(vegRow.due, 0, 'an untouched category has zero due'); + assertEqual(vegRow.total_expenses, 0, 'categories do not leak each other\'s ledger totals'); + + // A payment larger than the outstanding due is allowed and can go negative. + const overpay = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: vegId, amount: 1000, method: 'card' }, headers: ownerAuth }); + assertEqual(overpay.status, 201, 'a payment exceeding the current due is accepted, not rejected'); + const afterOverpay = await api(baseUrl, '/api/expenses/categories', { headers: ownerAuth }); + assertEqual(afterOverpay.data.categories.find((c: any) => c.id === vegId).due, -1000, 'due can legitimately go negative after an overpayment'); + + // ── Category deletion rule ───────────────────────────────────────────── + const deleteWithDue = await api(baseUrl, `/api/expenses/categories/${chickenId}`, { method: 'DELETE', headers: ownerAuth }); + assertEqual(deleteWithDue.status, 400, 'a category with a nonzero due cannot be deleted'); + + const cashierDeleteAttempt = await api(baseUrl, `/api/expenses/categories/${vegId}`, { method: 'DELETE', headers: cashier.authHeader }); + assertEqual(cashierDeleteAttempt.status, 403, 'cashier cannot delete an expense category'); + + // Settle chicken's due to zero, then deletion should succeed. + const settle = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: chickenId, amount: 250, method: 'upi' }, headers: ownerAuth }); + assertEqual(settle.status, 201, 'settling payment recorded'); + const deleteSettled = await api(baseUrl, `/api/expenses/categories/${chickenId}`, { method: 'DELETE', headers: ownerAuth }); + assertEqual(deleteSettled.status, 200, 'a fully-settled category can be deleted'); + + const listAfterDelete = await api(baseUrl, '/api/expenses/categories', { headers: ownerAuth }); + assert(!listAfterDelete.data.categories.some((c: any) => c.id === chickenId), 'deleted category no longer appears in the default list'); + + // Historical entries/payments against the deleted category are preserved. + const historicalEntries = await api(baseUrl, `/api/expenses/entries?category_id=${chickenId}`, { headers: ownerAuth }); + assertEqual(historicalEntries.data.entries.length, 5, 'deleting a category preserves its historical expense entries'); + + const inactiveListForOwner = await api(baseUrl, '/api/expenses/categories?include_inactive=true', { headers: ownerAuth }); + assert(inactiveListForOwner.data.categories.some((c: any) => c.id === chickenId), 'owner can still see the soft-deleted category with include_inactive'); + + // ── Business-date validation ─────────────────────────────────────────── + const badCalendar = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: vegId, amount: 5, date: '2026-02-30' }, headers: ownerAuth }); + assertEqual(badCalendar.status, 400, 'a non-existent calendar date is rejected'); + + // ── Custom payment methods ride the same validation as bill payments ─── + db.prepare(`INSERT INTO payment_methods (name, is_active, sort_order, created_at, updated_at) VALUES ('Cheque', 1, 10, ?, ?)`).run(now(), now()); + const chequePay = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: vegId, amount: 40, method: 'Cheque' }, headers: ownerAuth }); + assertEqual(chequePay.status, 201, 'a payment with an active custom method is accepted'); + assertEqual(chequePay.data.payment.method, 'Cheque', 'the stored method keeps the canonical custom name'); + const chequeLower = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: vegId, amount: 5, method: 'cheque' }, headers: ownerAuth }); + assertEqual(chequeLower.status, 201, 'custom method matching is case-insensitive'); + assertEqual(chequeLower.data.payment.method, 'Cheque', 'a lowercase custom method is stored under its canonical name'); + db.prepare(`UPDATE payment_methods SET is_active = 0 WHERE name = 'Cheque'`).run(); + const inactivePay = await api(baseUrl, '/api/expenses/payments', { method: 'POST', body: { category_id: vegId, amount: 5, method: 'Cheque' }, headers: ownerAuth }); + assertEqual(inactivePay.status, 400, 'a payment with a deactivated custom method is rejected'); + const customSummary = await api(baseUrl, `/api/expenses/summary?month=${utcTodayDate().slice(0, 7)}`, { headers: ownerAuth }); + assertEqual(customSummary.status, 200, 'summary loads after custom-method payments'); + const vegCustomRow = customSummary.data.categories.find((c: any) => c.category_id === vegId); + assertEqual(vegCustomRow.custom_payments.Cheque, 45, 'the monthly report splits custom methods out of the built-in trio'); + assertEqual(customSummary.data.overall.custom_payments.Cheque, 45, 'overall custom totals accumulate across categories'); + + // ── Void: typo correction without rewriting history ──────────────────── + const typoEntry = await api(baseUrl, '/api/expenses/entries', { method: 'POST', body: { category_id: vegId, amount: 500 }, headers: ownerAuth }); + const typoId = typoEntry.data.entry.id; + const voidForbidden = await api(baseUrl, `/api/expenses/entries/${typoId}/void`, { method: 'POST', headers: cashier.authHeader }); + assertEqual(voidForbidden.status, 403, 'cashier cannot void an entry'); + const voided = await api(baseUrl, `/api/expenses/entries/${typoId}/void`, { method: 'POST', headers: ownerAuth }); + assertEqual(voided.status, 200, 'owner voids a mistyped entry'); + const afterVoid = await api(baseUrl, '/api/expenses/categories', { headers: ownerAuth }); + assertEqual(afterVoid.data.categories.find((c: any) => c.id === vegId).due, -1045, 'voiding removes the entry from the due'); + const doubleVoid = await api(baseUrl, `/api/expenses/entries/${typoId}/void`, { method: 'POST', headers: ownerAuth }); + assertEqual(doubleVoid.status, 404, 'voiding twice is rejected'); + const missingVoid = await api(baseUrl, '/api/expenses/entries/999999/void', { method: 'POST', headers: ownerAuth }); + assertEqual(missingVoid.status, 404, 'voiding an unknown entry is rejected'); + const ledgerAfterVoid = await api(baseUrl, `/api/expenses/entries?category_id=${vegId}&limit=100`, { headers: ownerAuth }); + assert(!ledgerAfterVoid.data.entries.some((e: any) => e.id === typoId), 'voided entries disappear from ledger reads'); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + closeDatabase(); + } + + const results = getResults(); + console.log(`\n${results.passed}/${results.total} passed`); + if (results.failed) process.exit(1); +} + +main().catch((error: unknown) => { console.error(error); process.exit(1); }); diff --git a/tests/helpers/test-setup.ts b/tests/helpers/test-setup.ts index 23eabf48c..8dd96a461 100644 --- a/tests/helpers/test-setup.ts +++ b/tests/helpers/test-setup.ts @@ -405,6 +405,18 @@ async function api( return { status: response.status, data }; } +// Express's default handler for an unmatched route returns an HTML body, not +// JSON — the api() helper's automatic response.json() would throw on that, so +// route-non-existence checks use a raw fetch and only look at the status. +async function rawStatus(baseUrl: string, urlPath: string, method: string, headers: Record): Promise { + const response = await (globalThis as any).fetch(baseUrl + urlPath, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: method === 'GET' || method === 'DELETE' ? undefined : '{}', + }); + return response.status; +} + // ── Exports ────────────────────────────────────────────────────────────────── module.exports = { @@ -423,6 +435,7 @@ module.exports = { // Express createApp, startServer, + rawStatus, // Seed data seedOwnerUser, diff --git a/tests/translations.test.ts b/tests/translations.test.ts index e117ea681..56387d13e 100644 --- a/tests/translations.test.ts +++ b/tests/translations.test.ts @@ -376,9 +376,14 @@ function tagParityErrors(enFlat: Record, localeFlat: Record = new Set([ 'auth.emailPlaceholder', // example email + 'cashCounter.cashRefunds', // pending translation (English fallback) 'common.appTitle', // brand 'common.brandName', // brand + 'common.confirmVoid', // pending translation (English fallback) 'common.logoAlt', // brand + 'common.void', // pending translation (English fallback) + 'common.voided', // pending translation (English fallback) + 'expenses.paymentMethodUpi', // technical acronym (Unified Payments Interface) 'kds.emptyColumn', // em dash 'pos.addonPrice', // pure format: +{currency}{price} 'pos.loadingEllipsis', // ellipsis @@ -388,10 +393,9 @@ const FA_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'printTest.paperWidth58', // measurement 'printTest.paperWidth80', // measurement 'products.addonSelectionRange', // pure format: {min} – {max} - 'setup.ownerEmailPlaceholder', // example email + 'serverApp.emailPlaceholder', // example email 'settings.apiKeyInputPlaceholder', // example API key 'settings.connectionUsb', // technical acronym - 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.instagramPlaceholder', // example handle 'settings.ipAddressPlaceholder', // example IP 'settings.kds', // technical acronym @@ -400,12 +404,13 @@ const FA_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'settings.paperWidth58', // measurement 'settings.paperWidth80', // measurement 'settings.paperWidth80Safe', // measurement + 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.portPlaceholder', // example port 'settings.registrationEmailPlaceholder', // example email 'settings.registrationLastError', // pure placeholder: {error} - 'serverApp.emailPlaceholder', // example email 'settings.revflo', // brand 'settings.tabOrderflow', // brand + 'setup.ownerEmailPlaceholder', // example email 'whatsapp.connect.pairingPhonePlaceholder', // pure format: {dialCode}XXXXXXXXXX ]); @@ -429,6 +434,7 @@ function faFallbackErrors(faFlat: Record, enFlat: Record = new Set([ 'auth.emailPlaceholder', // example email 'businessType.restaurant', // same word in French + 'cashCounter.date', // same word in French 'common.appTitle', // brand 'common.brandName', // brand 'common.logoAlt', // brand @@ -439,6 +445,9 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'customers.columnDate', // same word in French 'customers.columnDescription', // same word in French 'customers.columnPoints', // same word in French + 'expenses.date', // same word in French + 'expenses.note', // same word in French + 'expenses.paymentMethodUpi', // technical acronym (payment rail name) 'kds.emptyColumn', // em dash 'kds.tableLabel', // same word in French 'kds.viewKanban', // product term @@ -448,6 +457,7 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'nav.tables', // same word in French 'nav.whatsapp', // product name 'orders.tableAt', // same word in French + 'permissionMatrix.areas.menu', // same word in French 'pos.addonPrice', // pure format: +{currency}{price} 'pos.loadingEllipsis', // ellipsis 'pos.loyaltyPointsShort', // standard abbreviation @@ -455,13 +465,13 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'pos.tagCount', // pure format: {tag} ×{count} 'pos.taxLine', // pure format: {title} @{rate}% 'pos.total', // same word in French + 'print.grandTotal', // receipt convention + 'print.hsn', // technical acronym + 'print.kot.type', // same word in French + 'print.note', // same word in French 'printTest.escpos', // technical acronym 'printTest.paperWidth58', // measurement 'printTest.paperWidth80', // measurement - 'print.note', // same word in French - 'print.grandTotal', // receipt convention - 'print.kot.type', // same word in French - 'print.hsn', // technical acronym 'products.addonSelectionRange', // pure format: {min} – {max} 'products.cashbackGlobalBadge', // same word in French 'products.categoryDescription', // same word in French @@ -480,7 +490,6 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'serverApp.tables', // same word in French 'settings.apiKeyInputPlaceholder', // example API key 'settings.connectionUsb', // technical acronym - 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.instagramPlaceholder', // example handle 'settings.ipAddressPlaceholder', // example IP 'settings.iranCurrencyDisplayRial', // currency name and native script @@ -491,6 +500,7 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'settings.paperWidth58', // measurement 'settings.paperWidth80', // measurement 'settings.paperWidth80Safe', // measurement + 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.port', // same word in French 'settings.portPlaceholder', // example port 'settings.registrationEmailPlaceholder', // example email @@ -505,12 +515,11 @@ const FR_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'setup.ownerEmailPlaceholder', // example email 'setup.pinLabel', // technical acronym 'staff.roleChef', // same loanword in French UI - 'permissionMatrix.areas.menu', // same word in French 'support.restaurant', // same word in French 'support.version', // same word in French + 'tables.floorplanAuto', // same word in French ("Auto") 'tables.section', // same word in French 'tables.title', // same word in French - 'tables.floorplanAuto', // same word in French ("Auto") 'tax.actions', // same word in French 'tax.auditCreateOverride', // pure format with identifiers 'tax.type', // same word in French @@ -531,12 +540,84 @@ function frFallbackErrors(frFlat: Record, enFlat: Record([ - 'settings.paymentMethodUpi', // technical acronym (payment rail name) + 'cashCounter.cashExpenses', // pending translation (English fallback) + 'cashCounter.cashFromOrders', // pending translation (English fallback) + 'cashCounter.cashRefunds', // pending translation (English fallback) + 'cashCounter.countLog', // pending translation (English fallback) + 'cashCounter.countRecorded', // pending translation (English fallback) + 'cashCounter.counted', // pending translation (English fallback) + 'cashCounter.countedAmount', // pending translation (English fallback) + 'cashCounter.date', // pending translation (English fallback) + 'cashCounter.expectedCash', // pending translation (English fallback) + 'cashCounter.failedToLoad', // pending translation (English fallback) + 'cashCounter.failedToLoadMonthly', // pending translation (English fallback) + 'cashCounter.monthlyReport', // pending translation (English fallback) + 'cashCounter.netCash', // pending translation (English fallback) + 'cashCounter.noCounts', // pending translation (English fallback) + 'cashCounter.noPayments', // pending translation (English fallback) + 'cashCounter.openingFloat', // pending translation (English fallback) + 'cashCounter.openingFloatAlreadySet', // pending translation (English fallback) + 'cashCounter.openingFloatSet', // pending translation (English fallback) + 'cashCounter.overall', // pending translation (English fallback) + 'cashCounter.recordCount', // pending translation (English fallback) + 'cashCounter.recordedBy', // pending translation (English fallback) + 'cashCounter.selectDate', // pending translation (English fallback) + 'cashCounter.selectMonth', // pending translation (English fallback) + 'cashCounter.setOpeningFloat', // pending translation (English fallback) + 'cashCounter.title', // pending translation (English fallback) + 'cashCounter.variance', // pending translation (English fallback) + 'cashCounter.varianceMatch', // pending translation (English fallback) + 'cashCounter.varianceOverage', // pending translation (English fallback) + 'cashCounter.varianceShortage', // pending translation (English fallback) 'common.appTitle', // brand name "Flo" 'common.brandName', // brand name "Flo Cafe" + 'common.confirmVoid', // pending translation (English fallback) 'common.logoAlt', // brand name "Flo Cafe" + 'common.void', // pending translation (English fallback) + 'common.voided', // pending translation (English fallback) + 'expenses.addCategory', // pending translation (English fallback) + 'expenses.addExpense', // pending translation (English fallback) + 'expenses.category', // pending translation (English fallback) + 'expenses.categoryCreated', // pending translation (English fallback) + 'expenses.categoryDeleted', // pending translation (English fallback) + 'expenses.categoryName', // pending translation (English fallback) + 'expenses.clearDateFilter', // pending translation (English fallback) + 'expenses.confirmDeleteCategory', // pending translation (English fallback) + 'expenses.date', // pending translation (English fallback) + 'expenses.deleteCategory', // pending translation (English fallback) + 'expenses.deleteCategoryBlocked', // pending translation (English fallback) + 'expenses.due', // pending translation (English fallback) + 'expenses.entryAdded', // pending translation (English fallback) + 'expenses.entryTypeExpense', // pending translation (English fallback) + 'expenses.entryTypePayment', // pending translation (English fallback) + 'expenses.failedToLoad', // pending translation (English fallback) + 'expenses.failedToLoadSummary', // pending translation (English fallback) + 'expenses.filterByDate', // pending translation (English fallback) + 'expenses.history', // pending translation (English fallback) + 'expenses.monthlyReport', // pending translation (English fallback) + 'expenses.noCategories', // pending translation (English fallback) + 'expenses.noEntries', // pending translation (English fallback) + 'expenses.note', // pending translation (English fallback) + 'expenses.overall', // pending translation (English fallback) + 'expenses.paymentMethod', // pending translation (English fallback) + 'expenses.paymentMethodCard', // pending translation (English fallback) + 'expenses.paymentMethodCash', // pending translation (English fallback) + 'expenses.paymentMethodUpi', // pending translation (English fallback) + 'expenses.paymentRecorded', // pending translation (English fallback) + 'expenses.recordPayment', // pending translation (English fallback) + 'expenses.recordedBy', // pending translation (English fallback) + 'expenses.selectMonth', // pending translation (English fallback) + 'expenses.title', // pending translation (English fallback) + 'expenses.totalExpenses', // pending translation (English fallback) + 'expenses.totalPaid', // pending translation (English fallback) + 'nav.cashCounter', // pending translation (English fallback) + 'nav.expenses', // pending translation (English fallback) 'nav.portLabel', // technical term "Port" 'nav.whatsapp', // product name "WhatsApp" + 'permissionMatrix.areas.expenses', // pending translation (English fallback) + 'permissionMatrix.capabilities.cashCounterRecord', // pending translation (English fallback) + 'permissionMatrix.capabilities.expenseCategoriesManage', // pending translation (English fallback) + 'permissionMatrix.capabilities.expenseEntriesRecord', // pending translation (English fallback) 'pos.addonPrice', // pure format "+{currency}{price}" 'pos.loadingEllipsis', // pure symbol "…" 'pos.tagCount', // pure format "{tag} ×{count}" @@ -552,6 +633,7 @@ const TR_INTENTIONAL_IDENTICAL = new Set([ 'products.tagVegan', // universal dietary term "Vegan" 'settings.ipAddressPlaceholder', // example IP "192.168.1.100" 'settings.iranNumberDigitsLatin', // script name "Latin (0-9)" + 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.plan', // loanword / term "Plan" 'settings.port', // technical term "Port" 'settings.revflo', // brand name "RevFlo" @@ -584,11 +666,43 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'auth.email', 'auth.password', 'auth.recoverPinLabel', + 'cashCounter.cashExpenses', // pending translation (English fallback) + 'cashCounter.cashFromOrders', // pending translation (English fallback) + 'cashCounter.cashRefunds', // pending translation (English fallback) + 'cashCounter.countLog', // pending translation (English fallback) + 'cashCounter.countRecorded', // pending translation (English fallback) + 'cashCounter.counted', // pending translation (English fallback) + 'cashCounter.countedAmount', // pending translation (English fallback) + 'cashCounter.date', // pending translation (English fallback) + 'cashCounter.expectedCash', // pending translation (English fallback) + 'cashCounter.failedToLoad', // pending translation (English fallback) + 'cashCounter.failedToLoadMonthly', // pending translation (English fallback) + 'cashCounter.monthlyReport', // pending translation (English fallback) + 'cashCounter.netCash', // pending translation (English fallback) + 'cashCounter.noCounts', // pending translation (English fallback) + 'cashCounter.noPayments', // pending translation (English fallback) + 'cashCounter.openingFloat', // pending translation (English fallback) + 'cashCounter.openingFloatAlreadySet', // pending translation (English fallback) + 'cashCounter.openingFloatSet', // pending translation (English fallback) + 'cashCounter.overall', // pending translation (English fallback) + 'cashCounter.recordCount', // pending translation (English fallback) + 'cashCounter.recordedBy', // pending translation (English fallback) + 'cashCounter.selectDate', // pending translation (English fallback) + 'cashCounter.selectMonth', // pending translation (English fallback) + 'cashCounter.setOpeningFloat', // pending translation (English fallback) + 'cashCounter.title', // pending translation (English fallback) + 'cashCounter.variance', // pending translation (English fallback) + 'cashCounter.varianceMatch', // pending translation (English fallback) + 'cashCounter.varianceOverage', // pending translation (English fallback) + 'cashCounter.varianceShortage', // pending translation (English fallback) 'common.appTitle', 'common.brandName', + 'common.confirmVoid', // pending translation (English fallback) 'common.discount', 'common.logoAlt', 'common.subtotal', + 'common.void', // pending translation (English fallback) + 'common.voided', // pending translation (English fallback) 'customer.email', 'customer.loyalty', 'customer.ptsSuffix', @@ -599,11 +713,48 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'dashboard.minutesValue', 'dashboard.title', 'dashboard.walkIn', + 'expenses.addCategory', // pending translation (English fallback) + 'expenses.addExpense', // pending translation (English fallback) + 'expenses.category', // pending translation (English fallback) + 'expenses.categoryCreated', // pending translation (English fallback) + 'expenses.categoryDeleted', // pending translation (English fallback) + 'expenses.categoryName', // pending translation (English fallback) + 'expenses.clearDateFilter', // pending translation (English fallback) + 'expenses.confirmDeleteCategory', // pending translation (English fallback) + 'expenses.date', // pending translation (English fallback) + 'expenses.deleteCategory', // pending translation (English fallback) + 'expenses.deleteCategoryBlocked', // pending translation (English fallback) + 'expenses.due', // pending translation (English fallback) + 'expenses.entryAdded', // pending translation (English fallback) + 'expenses.entryTypeExpense', // pending translation (English fallback) + 'expenses.entryTypePayment', // pending translation (English fallback) + 'expenses.failedToLoad', // pending translation (English fallback) + 'expenses.failedToLoadSummary', // pending translation (English fallback) + 'expenses.filterByDate', // pending translation (English fallback) + 'expenses.history', // pending translation (English fallback) + 'expenses.monthlyReport', // pending translation (English fallback) + 'expenses.noCategories', // pending translation (English fallback) + 'expenses.noEntries', // pending translation (English fallback) + 'expenses.note', // pending translation (English fallback) + 'expenses.overall', // pending translation (English fallback) + 'expenses.paymentMethod', // pending translation (English fallback) + 'expenses.paymentMethodCard', // pending translation (English fallback) + 'expenses.paymentMethodCash', // pending translation (English fallback) + 'expenses.paymentMethodUpi', // pending translation (English fallback) + 'expenses.paymentRecorded', // pending translation (English fallback) + 'expenses.recordPayment', // pending translation (English fallback) + 'expenses.recordedBy', // pending translation (English fallback) + 'expenses.selectMonth', // pending translation (English fallback) + 'expenses.title', // pending translation (English fallback) + 'expenses.totalExpenses', // pending translation (English fallback) + 'expenses.totalPaid', // pending translation (English fallback) 'kds.connectionLive', 'kds.modalOrderNumber', 'kds.viewKanban', 'kds.viewTabs', + 'nav.cashCounter', // pending translation (English fallback) 'nav.dashboard', + 'nav.expenses', // pending translation (English fallback) 'nav.kds', 'nav.portLabel', 'nav.pos', @@ -615,6 +766,13 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'orders.online', 'orders.overridePinLabel', 'orders.takeaway', + 'permissionMatrix.areas.expenses', // pending translation (English fallback) + 'permissionMatrix.areas.staff', + 'permissionMatrix.areas.system', + 'permissionMatrix.capabilities.cashCounterRecord', // pending translation (English fallback) + 'permissionMatrix.capabilities.expenseCategoriesManage', // pending translation (English fallback) + 'permissionMatrix.capabilities.expenseEntriesRecord', // pending translation (English fallback) + 'permissionMatrix.managerDescription', 'pos.addonPrice', 'pos.billNumber', 'pos.cart', @@ -630,8 +788,7 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'pos.methodCard', 'pos.methodCash', 'pos.methodWallet', - 'settings.paymentMethodCard', - 'settings.paymentMethodCash', + 'pos.numericKeypad', 'pos.orderNumber', 'pos.orderTypeDelivery', 'pos.orderTypeOnline', @@ -648,18 +805,17 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'pos.tagOrganic', 'pos.tagVegan', 'pos.taxLine', - 'pos.numericKeypad', + 'print.address', + 'print.customerShort', + 'print.kot.banner', + 'print.taxInvoiceTitle', + 'print.test.title', 'printTest.escpos', - 'printTest.kitchenStation', // English-identical station sample data 'printTest.item', + 'printTest.kitchenStation', // English-identical station sample data + 'printTest.optionWebPrint', // technical browser print mode label 'printTest.paperWidth58', 'printTest.paperWidth80', - 'printTest.optionWebPrint', // technical browser print mode label - 'print.taxInvoiceTitle', - 'print.customerShort', - 'print.address', - 'print.kot.banner', - 'print.test.title', 'products.addonSelectionRange', 'products.barcodeLabel', 'products.cashbackGlobalBadge', @@ -707,10 +863,9 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'settings.backupSchemaVersion', 'settings.billTemplateCompactName', 'settings.browserWebusb', + 'settings.cashDrawerPulseEnabledShort', 'settings.connectionNetwork', 'settings.connectionUsb', - 'settings.paymentMethodUpi', // technical acronym (payment rail name) - 'settings.cashDrawerPulseEnabledShort', 'settings.currency', 'settings.default', 'settings.defaultPrinter', @@ -745,6 +900,9 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'settings.paperWidth58', 'settings.paperWidth80', 'settings.paperWidth80Safe', + 'settings.paymentMethodCard', + 'settings.paymentMethodCash', + 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.percentMaximum', 'settings.plan', 'settings.port', @@ -764,13 +922,13 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'settings.tabOrderflow', 'settings.tabWhatsapp', 'settings.taxIdLabel', + 'settings.themeSystem', 'settings.timezone', 'settings.unicode', 'settings.updateStatusAvailable', 'settings.updateStatusOffline', 'settings.vpnMeshNetwork', 'settings.whatsapp', - 'settings.themeSystem', 'setup.cloudUrlLabel', 'setup.demoLabel', 'setup.expressLabel', @@ -785,9 +943,6 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'staff.passwordPlaceholder', 'staff.roleManager', 'staff.roleServer', - 'permissionMatrix.managerDescription', - 'permissionMatrix.areas.staff', - 'permissionMatrix.areas.system', 'support.email', 'support.platform', 'support.requestId', @@ -799,9 +954,9 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ 'tax.fixed', 'tax.readOnly', 'tax.target', - 'update.downloadingBadge', - 'update.betaOn', 'update.betaOff', + 'update.betaOn', + 'update.downloadingBadge', 'whatsapp.blocklist.title', 'whatsapp.connect.pairingMethodTitle', 'whatsapp.connect.pairingPhonePlaceholder', @@ -839,6 +994,7 @@ const DE_INTENTIONAL_IDENTICAL = new Set([ 'common.logoAlt', 'common.namePlaceholder', 'dashboard.title', + 'expenses.paymentMethodUpi', // technical acronym (payment rail name) 'kds.connectionLive', 'kds.emptyColumn', 'kds.viewKanban', @@ -883,13 +1039,13 @@ const DE_INTENTIONAL_IDENTICAL = new Set([ 'serverApp.emailPlaceholder', 'settings.apiKeyInputPlaceholder', 'settings.connectionUsb', - 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.errorDetails', 'settings.ipAddressPlaceholder', 'settings.iranCurrencyDisplayRial', 'settings.iranCurrencyDisplayToman', 'settings.kds', 'settings.name', + 'settings.paymentMethodUpi', // technical acronym (payment rail name) 'settings.port', 'settings.portPlaceholder', 'settings.printerOffline', diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts index 5751b4410..36855c43d 100644 --- a/tests/upgrade-path.test.ts +++ b/tests/upgrade-path.test.ts @@ -233,6 +233,40 @@ function main() { ); console.log(' ✓ old installs receive generic tax behavior without replacing legacy tax data'); + // ── Migration v82: expense tracker + cash counter tables ───────────────── + const requiredFinanceTables = ['expense_categories', 'expense_entries', 'expense_due_payments', 'cash_opening_floats', 'cash_count_records']; + for (const table of requiredFinanceTables) { + assert.ok( + db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table), + `${table} exists after upgrading an old install`, + ); + } + const expectedFinanceColumns: Record = { + expense_categories: ['id', 'name', 'is_active', 'deleted_at', 'created_at', 'updated_at', 'created_by'], + expense_entries: ['id', 'category_id', 'amount', 'note', 'expense_date', 'created_by', 'created_at'], + expense_due_payments: ['id', 'category_id', 'amount', 'note', 'payment_date', 'method', 'created_by', 'created_at'], + cash_opening_floats: ['id', 'date', 'amount', 'note', 'created_by', 'created_at'], + cash_count_records: ['id', 'date', 'counted_amount', 'note', 'created_by', 'created_at'], + }; + for (const [table, expected] of Object.entries(expectedFinanceColumns)) { + const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((column: any) => column.name); + for (const column of expected) { + assert.ok(columns.includes(column), `${table}.${column} exists after upgrading an old install`); + } + } + console.log(' ✓ old installs receive the v82 expense and cash counter tables'); + + // ── Migration v83: finance void flags ──────────────────────────────────── + for (const table of ['expense_entries', 'expense_due_payments', 'cash_opening_floats']) { + const columns = db.prepare(`PRAGMA table_info(${table})`).all().map((column: any) => column.name); + assert.ok(columns.includes('voided_at'), `${table}.voided_at exists after upgrading an old install`); + } + assert.ok( + db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = 'idx_cash_opening_floats_live_date'`).get(), + 'the live-rows-only float uniqueness index exists after upgrading an old install', + ); + console.log(' ✓ old installs receive the v83 void flags with live-only float uniqueness'); + assert.equal((db.prepare('SELECT COUNT(*) AS count FROM products').get() as any).count, 10); // A product originating in this pre-tax-engine fixture can still carry the // old tax_type/tax_rate columns after Phase 1, but those columns are not