From 50bfe37929172f8e7fe3d608cbd904f1200e7444 Mon Sep 17 00:00:00 2001 From: Amit kumar Date: Sat, 5 Sep 2026 12:05:16 +0530 Subject: [PATCH 1/7] feat: expense tracker and cash counter (#645) Port of kamit13/FloCafe@dev expense and cash-counter work onto current main. Migrations renumbered to v82-v85 (v75-v81 taken upstream). --- docs/roles-and-permissions.md | 5 + .../src/app/(dashboard)/cash-counter/page.tsx | 321 +++++++++++++++ .../src/app/(dashboard)/expenses/page.tsx | 364 ++++++++++++++++++ frontend/src/components/layout/Sidebar.tsx | 4 + frontend/src/lib/i18n/messages/en.json | 77 +++- frontend/src/lib/i18n/messages/es.json | 77 +++- frontend/src/lib/i18n/messages/fa.json | 77 +++- frontend/src/lib/i18n/messages/pt.json | 77 +++- frontend/src/lib/types.ts | 108 ++++++ main/db.ts | 211 +++++++++- main/routes/cash-counter.ts | 306 +++++++++++++++ main/routes/expenses.ts | 291 ++++++++++++++ main/routes/index.ts | 4 + package.json | 7 +- shared/role-permissions.ts | 6 +- tests/cash-counter.test.ts | 196 ++++++++++ tests/expenses.test.ts | 244 ++++++++++++ tests/translations.test.ts | 1 + tests/upgrade-path.test.ts | 41 ++ 19 files changed, 2400 insertions(+), 17 deletions(-) create mode 100644 frontend/src/app/(dashboard)/cash-counter/page.tsx create mode 100644 frontend/src/app/(dashboard)/expenses/page.tsx create mode 100644 main/routes/cash-counter.ts create mode 100644 main/routes/expenses.ts create mode 100644 tests/cash-counter.test.ts create mode 100644 tests/expenses.test.ts diff --git a/docs/roles-and-permissions.md b/docs/roles-and-permissions.md index 20761e8eb..51e200f00 100644 --- a/docs/roles-and-permissions.md +++ b/docs/roles-and-permissions.md @@ -56,6 +56,9 @@ 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 | Log opening floats and cash counts | ✓ | ✓ | ✓ | ✓ | ✓ | ## Important scope notes @@ -65,6 +68,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 or delete an individual expense/payment entry once recorded; it is a permanent audit trail. 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 paid out as expenses); 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..b6010d9a9 --- /dev/null +++ b/frontend/src/app/(dashboard)/cash-counter/page.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import api from '@/lib/api'; +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'; + +// UTC calendar day — matches the backend's utcTodayDate() convention (see +// the /expenses page, which shares this same date discipline). +function todayUtcDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function currentUtcMonth(): string { + return todayUtcDate().slice(0, 7); +} + +export default function CashCounterPage() { + const t = useTranslations('cashCounter'); + const tCommon = useTranslations('common'); + + 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(''); + + const loadDaily = () => api.get('/cash-counter/daily', { params: { date } }) + .then(({ data }) => setDaily(data)) + .catch(() => toast.error(t('failedToLoad'))) + .finally(() => setLoadingDaily(false)); + + const loadMonthly = (m: string) => api.get('/cash-counter/monthly', { params: { month: m } }) + .then(({ data }) => setMonthly(data)) + .catch(() => 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 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 ( +
+
+

{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 || !daily ? ( +

{tCommon('loading')}

+ ) : ( + <> +
+
+

{t('openingFloat')}

+

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

+
+
+

{t('cashFromOrders')}

+

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

+
+
+

{t('cashExpenses')}

+

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

+
+
+

{t('expectedCash')}

+

{daily.expected_cash.toFixed(2)}

+
+
+ +
+ + +
+ +
+
+

{t('countLog')}

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

{t('noCounts')}

+ ) : ( +
+ {daily.counts.map((count) => ( +
+
+

{count.counted_amount.toFixed(2)}

+

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

+
+

+ {varianceLabel(Math.round((count.counted_amount - daily.expected_cash) * 100) / 100)} +

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

{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('cashExpenses')}{t('expectedCash')}{t('counted')}{t('variance')}
{day.date}{day.opening_float.toFixed(2)}{day.cash_from_orders.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_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..5324aa2f0 --- /dev/null +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -0,0 +1,364 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import api from '@/lib/api'; +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']; + +// 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. +function todayUtcDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function currentUtcMonth(): string { + return todayUtcDate().slice(0, 7); +} + +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 [summaryMonth, setSummaryMonth] = useState(currentUtcMonth()); + const [summary, setSummary] = useState(null); + + const load = () => { + 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 }), + ]) + .then(([categoriesRes, entriesRes, paymentsRes]) => { + 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(() => toast.error(t('failedToLoad'))) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filterDate]); + + const loadSummary = (month: string) => api.get('/expenses/summary', { params: { month } }) + .then(({ data }) => setSummary(data)) + .catch(() => 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 openModal = (category: ExpenseCategory, mode: 'expense' | 'payment') => { + setActiveCategory(category); + setModalMode(mode); + setAmount(''); + setNote(''); + setDate(todayUtcDate()); + 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')}

; + + 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 && ( +
+ + + + + + + + + + + + + {summary.categories.map((row) => ( + + + + + + + + + ))} + + + + + + + + + + + +
{t('category')}{t('totalExpenses')}{t('totalPaid')}{t('paymentMethodCash')}{t('paymentMethodCard')}{t('paymentMethodUpi')}
{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)}
{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.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 ? ` · ${t(paymentMethodLabelKey(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)} +

+
+ ))} +
+ )} +
+ + {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' && ( +
+ +
+ {PAYMENT_METHODS.map((m) => ( + + ))} +
+
+ )} + 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/en.json b/frontend/src/lib/i18n/messages/en.json index 2384736a3..180ab3c1e 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -86,6 +86,36 @@ "businessType": { "restaurant": "Restaurant" }, + "cashCounter": { + "cashExpenses": "Cash Expenses", + "cashFromOrders": "Cash from Orders", + "counted": "Counted", + "countedAmount": "Counted amount", + "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", @@ -301,6 +331,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 +413,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 +1989,8 @@ "settings": "Settings", "integrations": "Integrations", "system": "System", - "support": "Support" + "support": "Support", + "expenses": "Expenses" }, "capabilities": { "pos": "Use the POS terminal", @@ -1966,7 +2036,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..2dcf49297 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -86,6 +86,36 @@ "businessType": { "restaurant": "Restaurante" }, + "cashCounter": { + "cashExpenses": "Gastos en Efectivo", + "cashFromOrders": "Efectivo de Pedidos", + "counted": "Contado", + "countedAmount": "Monto contado", + "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", @@ -301,6 +331,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 +413,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 +1989,8 @@ "settings": "Configuración", "integrations": "Integraciones", "system": "Sistema", - "support": "Soporte" + "support": "Soporte", + "expenses": "Gastos" }, "capabilities": { "pos": "Usar el terminal POS", @@ -1966,7 +2036,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..8cd5402a0 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -86,6 +86,36 @@ "businessType": { "restaurant": "رستوران" }, + "cashCounter": { + "cashExpenses": "هزینه‌های نقدی", + "cashFromOrders": "نقدی از سفارش‌ها", + "counted": "شمارش‌شده", + "countedAmount": "مبلغ شمارش‌شده", + "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": "افزودن", @@ -301,6 +331,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 +413,12 @@ "wsConnected": "WebSocket پیوند داده شد" }, "nav": { + "cashCounter": "شمارشگر نقدی", "collapse": "جمع کردن", "confirmLogout": "آیا از خروج مطمئن هستید؟", "customers": "مشتریان", "dashboard": "داشبورد", + "expenses": "هزینه‌ها", "heapLabel": "حافظه: ", "kds": "نمایشگر آشپزخانه", "logout": "خروج", @@ -1920,7 +1989,8 @@ "settings": "تنظیمات", "integrations": "یکپارچه‌سازی‌ها", "system": "سیستم", - "support": "پشتیبانی" + "support": "پشتیبانی", + "expenses": "هزینه‌ها" }, "capabilities": { "pos": "استفاده از پایانه فروش", @@ -1966,7 +2036,10 @@ "cloudAccountData": "مدیریت حساب و کنترل‌های داده ابری", "databaseTools": "استفاده از ابزار پایگاه‌داده و پشتیبان‌گیری", "serverApp": "استفاده از برنامه مستقل پیش‌خدمت", - "support": "تماس با پشتیبانی و مشاهده عیب‌یابی" + "support": "تماس با پشتیبانی و مشاهده عیب‌یابی", + "expenseCategoriesManage": "افزودن یا حذف دسته‌های هزینه", + "expenseEntriesRecord": "ثبت هزینه‌ها و پرداخت بدهی", + "cashCounterRecord": "ثبت موجودی اولیه و شمارش نقدی" } }, "support": { diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index 0e454b0f7..ac7742395 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -86,6 +86,36 @@ "businessType": { "restaurant": "Restaurante" }, + "cashCounter": { + "cashExpenses": "Despesas em Dinheiro", + "cashFromOrders": "Dinheiro de Pedidos", + "counted": "Contado", + "countedAmount": "Valor contado", + "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", @@ -301,6 +331,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 +413,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 +1989,8 @@ "settings": "Configurações", "integrations": "Integrações", "system": "Sistema", - "support": "Suporte" + "support": "Suporte", + "expenses": "Despesas" }, "capabilities": { "pos": "Usar o terminal POS", @@ -1966,7 +2036,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/types.ts b/frontend/src/lib/types.ts index e96540fd0..f8dfc5c52 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -259,6 +259,114 @@ 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: ExpensePaymentMethod | 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; +} + +export interface ExpenseMonthSummary { + month: string; + from: string; + to: string; + categories: ExpenseMonthSummaryCategory[]; + overall: { + total_expenses: number; + total_payments: number; + payments_by_method: 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_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_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_expenses: number; + net: number; + }; +} + export interface KitchenStation { id: number; name: string; diff --git a/main/db.ts b/main/db.ts index 32a842995..3bae10fbb 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,123 @@ 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'; `); + }, + }, + { + version: 82, + name: 'add_expense_tracker', + 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, + 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 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, + 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); + `); + }, + }, + { + version: 83, + name: 'add_expense_ledger_dates', + up: () => { + // Additive: expense_entries/expense_due_payments (v82) predate the + // business-date field. Added nullable — SQLite's ALTER TABLE ADD COLUMN + // rejects a non-constant default (e.g. date('now')), so this mirrors + // createSchema()'s plain `TEXT` column exactly (same lesson as + // products.cb_percent above: keep the migrated shape byte-identical to + // the ideal schema, or schema-health drifts forever). Existing rows are + // backfilled from created_at's date portion in the same transaction, so + // no real row is ever left with a null business date. + const entryColumns = getColumns(db, 'expense_entries'); + if (!entryColumns.includes('expense_date')) { + db.exec(`ALTER TABLE expense_entries ADD COLUMN expense_date TEXT`); + db.exec(`UPDATE expense_entries SET expense_date = substr(created_at, 1, 10)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_entries_category_date ON expense_entries(category_id, expense_date)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_entries_date ON expense_entries(expense_date)`); + } + const paymentColumns = getColumns(db, 'expense_due_payments'); + if (!paymentColumns.includes('payment_date')) { + db.exec(`ALTER TABLE expense_due_payments ADD COLUMN payment_date TEXT`); + db.exec(`UPDATE expense_due_payments SET payment_date = substr(created_at, 1, 10)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_due_payments_category_date ON expense_due_payments(category_id, payment_date)`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_due_payments_date ON expense_due_payments(payment_date)`); + } + }, + }, + { + version: 84, + name: 'add_expense_payment_method', + up: () => { + // How a due payment was settled — cash/card/upi, validated at the route + // layer (main/routes/expenses.ts), not via a DB CHECK constraint: a + // CHECK added through ALTER TABLE would also have to accept NULL for + // pre-existing rows, and the route is already the single source of + // truth for valid values. Nullable/no-default for the same ALTER TABLE + // constant-default reason as v83. + const columns = getColumns(db, 'expense_due_payments'); + if (!columns.includes('method')) { + db.exec(`ALTER TABLE expense_due_payments ADD COLUMN method TEXT`); + } + }, + }, + { + version: 85, + name: 'add_cash_counter', + up: () => { + db.exec(` + 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); `); }, }, @@ -4151,6 +4265,93 @@ 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) — SQLite's + -- ALTER TABLE ADD COLUMN only accepts a constant default, so v83's + -- guarded migration adds this same column without one; 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 + ); + + 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 (see v84). + 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); + + -- 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 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); + + -- 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..f1718b586 --- /dev/null +++ b/main/routes/cash-counter.ts @@ -0,0 +1,306 @@ +import { Router, Request, Response } from 'express'; +import expressRateLimit from 'express-rate-limit'; +import { getDatabase, now, utcTodayDate, utcDayBounds } from '../db'; +import { requireRole } from '../middleware/security'; +import { ROLE_ACCESS } from '../../shared/role-permissions'; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const MONTH_PATTERN = /^\d{4}-\d{2}$/; + +const router = Router(); +const cashCounterWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); + +/** + * Same "business date, defaults to today, may be backdated, never postdated" + * rule used by the expense tracker (main/routes/expenses.ts) — a cash count + * or opening float can't be logged for a day that hasn't happened yet. + */ +function normalizeRecordDate(value: unknown): string { + if (value === undefined || value === null || value === '') return utcTodayDate(); + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + throw Object.assign(new Error('date must be in YYYY-MM-DD format'), { statusCode: 400 }); + } + if (value > utcTodayDate()) { + throw Object.assign(new Error('date cannot be in the future'), { statusCode: 400 }); + } + return value; +} + +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 Math.round(amount * 100) / 100; +} + +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; +} + +function monthBounds(month: string): [string, string] { + if (!MONTH_PATTERN.test(month)) { + throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); + } + const [year, mon] = month.split('-').map(Number); + if (mon < 1 || mon > 12) { + throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); + } + const lastDay = new Date(Date.UTC(year, mon, 0)).getUTCDate(); + return [`${month}-01`, `${month}-${String(lastDay).padStart(2, '0')}`]; +} + +/** 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 }; + +/** + * Individual cash payment lines from bills.payment_details in a UTC + * half-open range [start, end). Deliberately mirrors the CTE shape in + * main/routes/reports.ts's paymentMethodBreakdown() — same JSON1 handling of + * legacy object-shaped payment_details, split-check bills (each a separate + * bills row), partial payments, and the timestamp -> paid_at -> created_at + * fallback — but returns row-level lines (reports.ts only aggregates) and is + * filtered to method = 'cash'. Not filtered by payment_status = 'paid': cash + * physically received counts immediately even if the rest of a + * partially-paid bill is still outstanding. + */ +function cashOrderPaymentLines(db: ReturnType, start: string, end: string): CashOrderPaymentLine[] { + return db.prepare(` + WITH payment_lines AS ( + SELECT b.id AS bill_id, b.bill_number, b.paid_at, b.created_at, je.value AS line + 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.payment_details IS NOT NULL + AND json_type(je.value) = 'object' + AND COALESCE(NULLIF(json_extract(je.value, '$.method'), ''), 'unknown') = 'cash' + ), normalized AS ( + SELECT + bill_id, bill_number, + json_extract(line, '$.amount') AS amount, + COALESCE( + datetime(NULLIF(json_extract(line, '$.timestamp'), '')), + datetime(NULLIF(paid_at, '')), + datetime(NULLIF(created_at, '')) + ) AS payment_time + FROM payment_lines + ) + SELECT bill_id, bill_number, + CASE WHEN typeof(amount) IN ('integer', 'real') THEN amount ELSE 0 END AS amount, + payment_time + FROM normalized + WHERE payment_time >= datetime(?) AND payment_time < datetime(?) + ORDER BY payment_time DESC + `).all(start, end) as CashOrderPaymentLine[]; +} + +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 = ? + 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 <= ? + 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])); +} + +router.get('/daily', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const date = normalizeRecordDate(req.query.date); + const db = getDatabase(); + const [start, end] = utcDayBounds(date); + + 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 = ? + `).get(date) as any; + + const orderLines = cashOrderPaymentLines(db, start, end); + const orderTotal = Math.round(orderLines.reduce((sum, line) => sum + line.amount, 0) * 100) / 100; + + const expensePayments = listCashExpensePayments(db, date) as any[]; + const expenseTotal = Math.round(expensePayments.reduce((sum, row) => sum + row.amount, 0) * 100) / 100; + + const openingAmount = openingFloat?.amount ?? 0; + const expectedCash = Math.round((openingAmount + orderTotal - expenseTotal) * 100) / 100; + + 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 ? Math.round((latestCount.counted_amount - expectedCash) * 100) / 100 : null; + + res.json({ + date, + opening_float: openingFloat || null, + cash_from_orders: { total: orderTotal, payments: orderLines }, + cash_expenses: { total: expenseTotal, payments: expensePayments }, + expected_cash: expectedCash, + 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 = normalizeRecordDate(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('/count', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { + try { + const date = normalizeRecordDate(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 : utcTodayDate().slice(0, 7); + const [from, to] = monthBounds(month); + const db = getDatabase(); + + const [rangeStart] = utcDayBounds(from); + const [, rangeEnd] = utcDayBounds(to); + const orderLines = cashOrderPaymentLines(db, rangeStart, rangeEnd); + const ordersByDate = new Map(); + for (const line of orderLines) { + const day = line.payment_time.slice(0, 10); + ordersByDate.set(day, Math.round(((ordersByDate.get(day) || 0) + line.amount) * 100) / 100); + } + + const expensesByDate = cashExpenseTotalsByDate(db, from, to); + const openingByDate = openingFloatsByDate(db, from, to); + const latestCountByDate = latestCountsByDate(db, from, to); + + let totalOpeningFloats = 0; + let totalCashFromOrders = 0; + let totalCashExpenses = 0; + + const days = datesInRange(from, to).map((date) => { + const opening = openingByDate.get(date) || 0; + const orders = ordersByDate.get(date) || 0; + const expenses = expensesByDate.get(date) || 0; + const expectedCash = Math.round((opening + orders - expenses) * 100) / 100; + const latestCount = latestCountByDate.has(date) ? latestCountByDate.get(date)! : null; + const variance = latestCount !== null ? Math.round((latestCount - expectedCash) * 100) / 100 : null; + + totalOpeningFloats = Math.round((totalOpeningFloats + opening) * 100) / 100; + totalCashFromOrders = Math.round((totalCashFromOrders + orders) * 100) / 100; + totalCashExpenses = Math.round((totalCashExpenses + expenses) * 100) / 100; + + return { + date, + opening_float: opening, + cash_from_orders: orders, + cash_expenses: expenses, + expected_cash: expectedCash, + latest_count: latestCount, + variance, + }; + }); + + res.json({ + month, + from, + to, + days, + totals: { + total_opening_floats: totalOpeningFloats, + total_cash_from_orders: totalCashFromOrders, + total_cash_expenses: totalCashExpenses, + net: Math.round((totalCashFromOrders - totalCashExpenses) * 100) / 100, + }, + }); + } 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..1f0ffc7f3 --- /dev/null +++ b/main/routes/expenses.ts @@ -0,0 +1,291 @@ +import { Router, Request, Response } from 'express'; +import expressRateLimit from 'express-rate-limit'; +import { getDatabase, now, generateShortId, utcTodayDate } from '../db'; +import { requireRole } from '../middleware/security'; +import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** + * The business date an expense/payment is FOR — defaults to today, may be + * backdated, but never postdated (a record can't be for a day that hasn't + * happened yet). Distinct from created_at (main/db.ts:5156's UTC-day + * convention — see utcTodayDate), which always stamps the real moment of + * recording and is never client-supplied. + */ +function normalizeEntryDate(value: unknown): string { + if (value === undefined || value === null || value === '') return utcTodayDate(); + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + throw Object.assign(new Error('date must be in YYYY-MM-DD format'), { statusCode: 400 }); + } + if (value > utcTodayDate()) { + throw Object.assign(new Error('date cannot be in the future'), { statusCode: 400 }); + } + return value; +} + +const PAYMENT_METHODS = ['cash', 'card', 'upi'] as const; +type PaymentMethod = typeof PAYMENT_METHODS[number]; + +function normalizePaymentMethod(value: unknown): PaymentMethod { + if (typeof value !== 'string' || !PAYMENT_METHODS.includes(value as PaymentMethod)) { + throw Object.assign(new Error(`method is required and must be one of: ${PAYMENT_METHODS.join(', ')}`), { statusCode: 400 }); + } + return value as PaymentMethod; +} + +const MONTH_PATTERN = /^\d{4}-\d{2}$/; + +/** [firstDay, lastDay] of a `YYYY-MM` month, both inclusive `YYYY-MM-DD` strings. */ +function monthBounds(month: string): [string, string] { + if (!MONTH_PATTERN.test(month)) { + throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); + } + const [year, mon] = month.split('-').map(Number); + if (mon < 1 || mon > 12) { + throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); + } + const lastDay = new Date(Date.UTC(year, mon, 0)).getUTCDate(); + return [`${month}-01`, `${month}-${String(lastDay).padStart(2, '0')}`]; +} + +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 Math.round(amount * 100) / 100; +} + +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; +} + +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 GROUP BY category_id) entries + ON entries.category_id = ec.id + LEFT JOIN (SELECT category_id, SUM(amount) AS total FROM expense_due_payments 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[]; + return rows.map((row) => ({ ...row, is_active: Boolean(row.is_active) })); +} + +function categoryDue(db: ReturnType, categoryId: string): number { + const entries = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE category_id = ?').get(categoryId) as { total: number }; + const payments = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE category_id = ?').get(categoryId) as { total: number }; + return Math.round((entries.total - payments.total) * 100) / 100; +} + +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 + `; + 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 = normalizeEntryDate(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) }); +}); + +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 = normalizeEntryDate(req.body?.date); + const method = normalizePaymentMethod(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 : utcTodayDate().slice(0, 7); + const [from, to] = monthBounds(month); + const db = getDatabase(); + + const categories = listCategories(false).map((category) => { + const expenses = db.prepare( + 'SELECT COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE category_id = ? AND expense_date >= ? AND expense_date <= ?' + ).get(category.id, from, to) as { total: number }; + const paymentsByMethod = db.prepare( + 'SELECT method, COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE category_id = ? AND payment_date >= ? AND payment_date <= ? GROUP BY method' + ).all(category.id, from, to) as { method: string | null; total: number }[]; + const byMethod: Record = { cash: 0, card: 0, upi: 0 }; + let totalPayments = 0; + for (const row of paymentsByMethod) { + totalPayments += row.total; + if (row.method && PAYMENT_METHODS.includes(row.method as PaymentMethod)) { + byMethod[row.method as PaymentMethod] = row.total; + } + } + return { + category_id: category.id, + category_name: category.name, + due: category.due, + total_expenses: expenses.total, + total_payments: Math.round(totalPayments * 100) / 100, + payments_by_method: byMethod, + }; + }); + + const overall = categories.reduce((acc, category) => { + acc.total_expenses += category.total_expenses; + acc.total_payments += category.total_payments; + acc.payments_by_method.cash += category.payments_by_method.cash; + acc.payments_by_method.card += category.payments_by_method.card; + acc.payments_by_method.upi += category.payments_by_method.upi; + return acc; + }, { total_expenses: 0, total_payments: 0, payments_by_method: { cash: 0, card: 0, upi: 0 } }); + + res.json({ month, from, to, categories, overall }); + } 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/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..72a8fac42 --- /dev/null +++ b/tests/cash-counter.test.ts @@ -0,0 +1,196 @@ +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, +} = require('./helpers/test-setup'); +const { orderRoutes } = require('../main/routes/orders'); +const { billRoutes } = require('../main/routes/bills'); +const { utcTodayDate } = require('../main/db'); + +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; +} + +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 } = seedOwnerUser(db); + 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'); + } 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..7ad0aa07a --- /dev/null +++ b/tests/expenses.test.ts @@ -0,0 +1,244 @@ +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, +} = require('./helpers/test-setup'); +const { expenseRoutes } = require('../main/routes/expenses'); +const { utcTodayDate } = require('../main/db'); + +// 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; +} + +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); + 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'); + } 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/translations.test.ts b/tests/translations.test.ts index e117ea681..b7a451bd5 100644 --- a/tests/translations.test.ts +++ b/tests/translations.test.ts @@ -379,6 +379,7 @@ const FA_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ 'common.appTitle', // brand 'common.brandName', // brand 'common.logoAlt', // brand + 'expenses.paymentMethodUpi', // technical acronym (Unified Payments Interface) 'kds.emptyColumn', // em dash 'pos.addonPrice', // pure format: +{currency}{price} 'pos.loadingEllipsis', // ellipsis diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts index 5751b4410..25d9b384e 100644 --- a/tests/upgrade-path.test.ts +++ b/tests/upgrade-path.test.ts @@ -233,6 +233,47 @@ function main() { ); console.log(' ✓ old installs receive generic tax behavior without replacing legacy tax data'); + // ── Migration v82: expense tracker tables ──────────────────────────────── + const requiredExpenseTables = ['expense_categories', 'expense_entries', 'expense_due_payments']; + for (const table of requiredExpenseTables) { + assert.ok( + db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table), + `${table} exists after upgrading an old install`, + ); + } + const expectedExpenseColumns: 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'], + }; + for (const [table, expected] of Object.entries(expectedExpenseColumns)) { + 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 tracker tables (expense_categories, expense_entries, expense_due_payments)'); + + // ── Migration v85: cash counter tables ──────────────────────────────────── + const requiredCashCounterTables = ['cash_opening_floats', 'cash_count_records']; + for (const table of requiredCashCounterTables) { + assert.ok( + db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table), + `${table} exists after upgrading an old install`, + ); + } + const expectedCashCounterColumns: Record = { + 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(expectedCashCounterColumns)) { + 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 v85 cash counter tables (cash_opening_floats, cash_count_records)'); + 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 From b8adce2d5ccf4bc4720195ae75d20dcf2cd87e9f Mon Sep 17 00:00:00 2001 From: Pablo Carvajal Date: Wed, 9 Sep 2026 14:15:05 -0300 Subject: [PATCH 2/7] fix: port repairs for expense/cash-counter on current main - Renumber migrations v75-v78 to v82-v85 (numbers taken upstream); drop best-seller v79 (out of scope). - Round category due to cents so float residue cannot block deletion. - Add de/fil/fr/tr locale keys (fr/de translated, fil/tr English fallback marked pending) and allow-list entries for translations gate. --- frontend/src/lib/i18n/messages/de.json | 77 ++++++++- frontend/src/lib/i18n/messages/fil.json | 77 ++++++++- frontend/src/lib/i18n/messages/fr.json | 77 ++++++++- frontend/src/lib/i18n/messages/tr.json | 77 ++++++++- main/routes/expenses.ts | 6 +- tests/translations.test.ts | 197 ++++++++++++++++++++---- 6 files changed, 475 insertions(+), 36 deletions(-) diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 4fc99e2e6..ac0142077 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -86,6 +86,36 @@ "businessType": { "restaurant": "Restaurant" }, + "cashCounter": { + "cashExpenses": "Barausgaben", + "cashFromOrders": "Bareinnahmen aus Bestellungen", + "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", @@ -301,6 +331,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 +413,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 +1989,8 @@ "settings": "Einstellungen", "integrations": "Integrationen", "system": "System", - "support": "Support" + "support": "Support", + "expenses": "Ausgaben" }, "capabilities": { "pos": "Kassenterminal bedienen", @@ -1966,7 +2036,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/fil.json b/frontend/src/lib/i18n/messages/fil.json index 222520b8e..8e4f6e6a8 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -85,6 +85,36 @@ }, "businessType": { "restaurant": "Restaurant & Cafe" + }, + "cashCounter": { + "cashExpenses": "Cash Expenses", + "cashFromOrders": "Cash from Orders", + "counted": "Counted", + "countedAmount": "Counted amount", + "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", @@ -300,6 +330,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 +413,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 +1989,8 @@ "settings": "Mga Setting", "integrations": "Mga Integration", "system": "System", - "support": "Suporta" + "support": "Suporta", + "expenses": "Expenses" }, "capabilities": { "pos": "Gamitin ang POS terminal", @@ -1966,7 +2036,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..21f9378fc 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -86,6 +86,36 @@ "businessType": { "restaurant": "Restaurant" }, + "cashCounter": { + "cashExpenses": "Dépenses en espèces", + "cashFromOrders": "Encaissé 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", @@ -301,6 +331,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 +413,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 +1989,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 +2036,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/tr.json b/frontend/src/lib/i18n/messages/tr.json index 7a1f770ff..30fda41ae 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -85,6 +85,36 @@ }, "businessType": { "restaurant": "Restoran & Kafe" + }, + "cashCounter": { + "cashExpenses": "Cash Expenses", + "cashFromOrders": "Cash from Orders", + "counted": "Counted", + "countedAmount": "Counted amount", + "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", @@ -300,6 +330,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 +413,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 +1989,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 +2036,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/main/routes/expenses.ts b/main/routes/expenses.ts index 1f0ffc7f3..6725124d6 100644 --- a/main/routes/expenses.ts +++ b/main/routes/expenses.ts @@ -99,7 +99,11 @@ function listCategories(includeInactive: boolean) { ${includeInactive ? '' : 'WHERE ec.deleted_at IS NULL AND ec.is_active = 1'} ORDER BY ec.name COLLATE NOCASE `).all() as any[]; - return rows.map((row) => ({ ...row, is_active: Boolean(row.is_active) })); + // 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: Math.round(row.due * 100) / 100 })); } function categoryDue(db: ReturnType, categoryId: string): number { diff --git a/tests/translations.test.ts b/tests/translations.test.ts index b7a451bd5..31b05dc62 100644 --- a/tests/translations.test.ts +++ b/tests/translations.test.ts @@ -430,6 +430,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 @@ -440,6 +441,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 @@ -449,6 +453,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 @@ -456,13 +461,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 @@ -481,7 +486,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 @@ -492,6 +496,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 @@ -506,12 +511,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 @@ -532,12 +536,80 @@ 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.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.logoAlt', // brand name "Flo Cafe" + '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}" @@ -553,6 +625,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" @@ -585,6 +658,34 @@ 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.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.discount', @@ -600,11 +701,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', @@ -616,6 +754,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', @@ -631,8 +776,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', @@ -649,18 +793,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', @@ -708,10 +851,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', @@ -746,6 +888,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', @@ -765,13 +910,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', @@ -786,9 +931,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', @@ -800,9 +942,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', @@ -840,6 +982,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', @@ -884,13 +1027,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', From 062f0d4cff3f6b1473cd8aa415ba41d0014f0237 Mon Sep 17 00:00:00 2001 From: Pablo Carvajal Date: Wed, 9 Sep 2026 14:35:10 -0300 Subject: [PATCH 3/7] refactor: review fixes for expense/cash-counter port - Squash migrations v82-v85 into one v82 (tables never shipped, no intermediate shape to preserve).\n- Share finance input validation and tenant timezone between the two finance routes (and the Z close).\n- Monthly expense summary in two grouped queries instead of per-category N+1.\n- Cash windows use store-timezone day bounds with paid_at attribution, same drawer rule as the Z close.\n- Expected cash subtracts cash refunds (cents converted at the boundary).\n- Round category due to cents; reject non-existent calendar dates.\n- Translate the new UI strings to fr/de; fil/tr carry marked English fallback. --- docs/roles-and-permissions.md | 2 +- .../src/app/(dashboard)/cash-counter/page.tsx | 44 ++-- .../src/app/(dashboard)/expenses/page.tsx | 12 +- frontend/src/lib/i18n/messages/de.json | 1 + frontend/src/lib/i18n/messages/en.json | 1 + frontend/src/lib/i18n/messages/es.json | 1 + frontend/src/lib/i18n/messages/fa.json | 1 + frontend/src/lib/i18n/messages/fil.json | 1 + frontend/src/lib/i18n/messages/fr.json | 1 + frontend/src/lib/i18n/messages/pt.json | 1 + frontend/src/lib/i18n/messages/tr.json | 1 + frontend/src/lib/types.ts | 3 + frontend/src/lib/utils.ts | 11 + main/db.ts | 71 ++----- main/routes/cash-closures.ts | 7 +- main/routes/cash-counter.ts | 201 +++++++++--------- main/routes/expenses.ts | 96 +++------ main/routes/finance-shared.ts | 62 ++++++ tests/cash-counter.test.ts | 54 ++++- tests/expenses.test.ts | 18 +- tests/helpers/test-setup.ts | 13 ++ tests/translations.test.ts | 9 +- tests/upgrade-path.test.ts | 30 +-- 23 files changed, 331 insertions(+), 310 deletions(-) create mode 100644 main/routes/finance-shared.ts diff --git a/docs/roles-and-permissions.md b/docs/roles-and-permissions.md index 51e200f00..549054a4a 100644 --- a/docs/roles-and-permissions.md +++ b/docs/roles-and-permissions.md @@ -69,7 +69,7 @@ A check means the role is allowed to use the capability. A dash means it is not - **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 or delete an individual expense/payment entry once recorded; it is a permanent audit trail. 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 paid out as expenses); 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. +- **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 index b6010d9a9..afcf61a54 100644 --- a/frontend/src/app/(dashboard)/cash-counter/page.tsx +++ b/frontend/src/app/(dashboard)/cash-counter/page.tsx @@ -2,22 +2,13 @@ import { useEffect, useState } from 'react'; import api from '@/lib/api'; +import { currentUtcMonth, 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'; -// UTC calendar day — matches the backend's utcTodayDate() convention (see -// the /expenses page, which shares this same date discipline). -function todayUtcDate(): string { - return new Date().toISOString().slice(0, 10); -} - -function currentUtcMonth(): string { - return todayUtcDate().slice(0, 7); -} - export default function CashCounterPage() { const t = useTranslations('cashCounter'); const tCommon = useTranslations('common'); @@ -132,6 +123,9 @@ export default function CashCounterPage() {

{t('cashFromOrders')}

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

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

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

+ )}

{t('cashExpenses')}

@@ -159,20 +153,23 @@ export default function CashCounterPage() {

{t('noCounts')}

) : (
- {daily.counts.map((count) => ( -
-
-

{count.counted_amount.toFixed(2)}

-

- {count.created_by_name ? t('recordedBy', { name: count.created_by_name }) : ''} - {count.note ? ` · ${count.note}` : ''} + {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)}

-

- {varianceLabel(Math.round((count.counted_amount - daily.expected_cash) * 100) / 100)} -

-
- ))} + ); + })}
)}
@@ -236,6 +233,7 @@ export default function CashCounterPage() { {t('date')} {t('openingFloat')} {t('cashFromOrders')} + {t('cashRefunds')} {t('cashExpenses')} {t('expectedCash')} {t('counted')} @@ -248,6 +246,7 @@ export default function CashCounterPage() { {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) : '—'} @@ -260,6 +259,7 @@ export default function CashCounterPage() { {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)} diff --git a/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx index 5324aa2f0..68647d65a 100644 --- a/frontend/src/app/(dashboard)/expenses/page.tsx +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import api from '@/lib/api'; +import { currentUtcMonth, 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'; @@ -15,17 +16,6 @@ type LedgerRow = ExpenseLedgerEntry & { kind: 'expense' | 'payment' }; const PAYMENT_METHODS: ExpensePaymentMethod[] = ['cash', 'card', 'upi']; -// 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. -function todayUtcDate(): string { - return new Date().toISOString().slice(0, 10); -} - -function currentUtcMonth(): string { - return todayUtcDate().slice(0, 7); -} - type PaymentMethodLabelKey = 'paymentMethodCash' | 'paymentMethodCard' | 'paymentMethodUpi'; function paymentMethodLabelKey(method: ExpensePaymentMethod): PaymentMethodLabelKey { diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index ac0142077..32f299ab7 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -89,6 +89,7 @@ "cashCounter": { "cashExpenses": "Barausgaben", "cashFromOrders": "Bareinnahmen aus Bestellungen", + "cashRefunds": "Rückerstattungen (bar)", "countLog": "Zählprotokoll", "countRecorded": "Kassenzählung gespeichert", "counted": "Gezählt", diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 180ab3c1e..023fdbca3 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -91,6 +91,7 @@ "cashFromOrders": "Cash from Orders", "counted": "Counted", "countedAmount": "Counted amount", + "cashRefunds": "Cash Refunds", "countLog": "Count Log", "countRecorded": "Cash count recorded", "date": "Date", diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index 2dcf49297..f57742474 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -91,6 +91,7 @@ "cashFromOrders": "Efectivo de Pedidos", "counted": "Contado", "countedAmount": "Monto contado", + "cashRefunds": "Reembolsos en efectivo", "countLog": "Registro de Conteos", "countRecorded": "Conteo de efectivo registrado", "date": "Fecha", diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index 8cd5402a0..fcac83183 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -91,6 +91,7 @@ "cashFromOrders": "نقدی از سفارش‌ها", "counted": "شمارش‌شده", "countedAmount": "مبلغ شمارش‌شده", + "cashRefunds": "Cash Refunds", "countLog": "گزارش شمارش‌ها", "countRecorded": "شمارش نقدی ثبت شد", "date": "تاریخ", diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 8e4f6e6a8..1b7dda5c1 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -91,6 +91,7 @@ "cashFromOrders": "Cash from Orders", "counted": "Counted", "countedAmount": "Counted amount", + "cashRefunds": "Cash Refunds", "countLog": "Count Log", "countRecorded": "Cash count recorded", "date": "Date", diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index 21f9378fc..ce76b1734 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -89,6 +89,7 @@ "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é", diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index ac7742395..ea806b8d4 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -91,6 +91,7 @@ "cashFromOrders": "Dinheiro de Pedidos", "counted": "Contado", "countedAmount": "Valor contado", + "cashRefunds": "Reembolsos em dinheiro", "countLog": "Registro de Contagens", "countRecorded": "Contagem de caixa registrada", "date": "Data", diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 30fda41ae..2e80b4347 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -91,6 +91,7 @@ "cashFromOrders": "Cash from Orders", "counted": "Counted", "countedAmount": "Counted amount", + "cashRefunds": "Cash Refunds", "countLog": "Count Log", "countRecorded": "Cash count recorded", "date": "Date", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f8dfc5c52..17187979d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -337,6 +337,7 @@ 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[]; @@ -348,6 +349,7 @@ 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; @@ -362,6 +364,7 @@ export interface CashMonthlySummary { totals: { total_opening_floats: number; total_cash_from_orders: number; + total_cash_refunds: number; total_cash_expenses: number; net: number; }; diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index a646579ae..d13b61e0a 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -11,3 +11,14 @@ 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) +} diff --git a/main/db.ts b/main/db.ts index 3bae10fbb..0ce80e8a0 100644 --- a/main/db.ts +++ b/main/db.ts @@ -4024,8 +4024,11 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ }, }, { + // 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', + name: 'add_expense_tracker_and_cash_counter', up: () => { db.exec(` CREATE TABLE IF NOT EXISTS expense_categories ( @@ -4046,76 +4049,32 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ 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); - `); - }, - }, - { - version: 83, - name: 'add_expense_ledger_dates', - up: () => { - // Additive: expense_entries/expense_due_payments (v82) predate the - // business-date field. Added nullable — SQLite's ALTER TABLE ADD COLUMN - // rejects a non-constant default (e.g. date('now')), so this mirrors - // createSchema()'s plain `TEXT` column exactly (same lesson as - // products.cb_percent above: keep the migrated shape byte-identical to - // the ideal schema, or schema-health drifts forever). Existing rows are - // backfilled from created_at's date portion in the same transaction, so - // no real row is ever left with a null business date. - const entryColumns = getColumns(db, 'expense_entries'); - if (!entryColumns.includes('expense_date')) { - db.exec(`ALTER TABLE expense_entries ADD COLUMN expense_date TEXT`); - db.exec(`UPDATE expense_entries SET expense_date = substr(created_at, 1, 10)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_entries_category_date ON expense_entries(category_id, expense_date)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_entries_date ON expense_entries(expense_date)`); - } - const paymentColumns = getColumns(db, 'expense_due_payments'); - if (!paymentColumns.includes('payment_date')) { - db.exec(`ALTER TABLE expense_due_payments ADD COLUMN payment_date TEXT`); - db.exec(`UPDATE expense_due_payments SET payment_date = substr(created_at, 1, 10)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_due_payments_category_date ON expense_due_payments(category_id, payment_date)`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_expense_due_payments_date ON expense_due_payments(payment_date)`); - } - }, - }, - { - version: 84, - name: 'add_expense_payment_method', - up: () => { - // How a due payment was settled — cash/card/upi, validated at the route - // layer (main/routes/expenses.ts), not via a DB CHECK constraint: a - // CHECK added through ALTER TABLE would also have to accept NULL for - // pre-existing rows, and the route is already the single source of - // truth for valid values. Nullable/no-default for the same ALTER TABLE - // constant-default reason as v83. - const columns = getColumns(db, 'expense_due_payments'); - if (!columns.includes('method')) { - db.exec(`ALTER TABLE expense_due_payments ADD COLUMN method TEXT`); - } - }, - }, - { - version: 85, - name: 'add_cash_counter', - up: () => { - db.exec(` + 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, @@ -4288,9 +4247,7 @@ function createSchema(): void { -- 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) — SQLite's - -- ALTER TABLE ADD COLUMN only accepts a constant default, so v83's - -- guarded migration adds this same column without one; every insert + -- 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), @@ -4314,7 +4271,7 @@ function createSchema(): void { -- 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 (see v84). + -- 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 diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index d307c946d..70288d95d 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -31,7 +31,7 @@ */ import { Router, Request, Response } from 'express'; import { - dayBoundsInTimezone, getDatabase, getSettingValue, localDateInTimezone, now, withTxn, + dayBoundsInTimezone, getDatabase, localDateInTimezone, now, withTxn, } from '../db'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS } from '../../shared/role-permissions'; @@ -40,6 +40,7 @@ import { getTenantCurrency } from '../services/refund'; import { getCurrencyMinorUnitFactor } from '../countries'; import { getOrdersWithItemsForBills } from './bills'; import { getHttpRequestSignal } from '../shutdown'; +import { tenantTimezone } from './finance-shared'; import { DisplayTaxComponent, aggregateTaxComponents, @@ -55,10 +56,6 @@ function httpError(message: string, statusCode: number): Error { return Object.assign(new Error(message), { statusCode }); } -function tenantTimezone(): string { - return getSettingValue('timezone') || 'Asia/Kolkata'; -} - function validateBusinessDate(raw: unknown): string { if (typeof raw !== 'string' || !ISO_DATE_RE.test(raw)) { throw httpError('business_date must use YYYY-MM-DD format', 400); diff --git a/main/routes/cash-counter.ts b/main/routes/cash-counter.ts index f1718b586..5283bee81 100644 --- a/main/routes/cash-counter.ts +++ b/main/routes/cash-counter.ts @@ -1,57 +1,28 @@ import { Router, Request, Response } from 'express'; import expressRateLimit from 'express-rate-limit'; -import { getDatabase, now, utcTodayDate, utcDayBounds } from '../db'; +import { dayBoundsInTimezone, getDatabase, localDateInTimezone, now, parseDbTimestamp, utcTodayDate } from '../db'; +import { getCurrencyMinorUnitFactor } from '../countries'; +import { getTenantCurrency } from '../services/refund'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS } from '../../shared/role-permissions'; - -const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; -const MONTH_PATTERN = /^\d{4}-\d{2}$/; - -const router = Router(); -const cashCounterWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); - -/** - * Same "business date, defaults to today, may be backdated, never postdated" - * rule used by the expense tracker (main/routes/expenses.ts) — a cash count - * or opening float can't be logged for a day that hasn't happened yet. - */ -function normalizeRecordDate(value: unknown): string { - if (value === undefined || value === null || value === '') return utcTodayDate(); - if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { - throw Object.assign(new Error('date must be in YYYY-MM-DD format'), { statusCode: 400 }); - } - if (value > utcTodayDate()) { - throw Object.assign(new Error('date cannot be in the future'), { statusCode: 400 }); - } - return value; -} +import { + monthBounds, + normalizeBusinessDate, + normalizeNote, + roundMoney, + 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 Math.round(amount * 100) / 100; + return roundMoney(amount); } -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; -} - -function monthBounds(month: string): [string, string] { - if (!MONTH_PATTERN.test(month)) { - throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); - } - const [year, mon] = month.split('-').map(Number); - if (mon < 1 || mon > 12) { - throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); - } - const lastDay = new Date(Date.UTC(year, mon, 0)).getUTCDate(); - return [`${month}-01`, `${month}-${String(lastDay).padStart(2, '0')}`]; -} +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[] { @@ -68,51 +39,63 @@ function datesInRange(from: string, to: string): string[] { type CashOrderPaymentLine = { bill_id: number; bill_number: string; amount: number; payment_time: string }; /** - * Individual cash payment lines from bills.payment_details in a UTC - * half-open range [start, end). Deliberately mirrors the CTE shape in - * main/routes/reports.ts's paymentMethodBreakdown() — same JSON1 handling of - * legacy object-shaped payment_details, split-check bills (each a separate - * bills row), partial payments, and the timestamp -> paid_at -> created_at - * fallback — but returns row-level lines (reports.ts only aggregates) and is - * filtered to method = 'cash'. Not filtered by payment_status = 'paid': cash - * physically received counts immediately even if the rest of a - * partially-paid bill is still outstanding. + * 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(` - WITH payment_lines AS ( - SELECT b.id AS bill_id, b.bill_number, b.paid_at, b.created_at, je.value AS line - 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.payment_details IS NOT NULL - AND json_type(je.value) = 'object' - AND COALESCE(NULLIF(json_extract(je.value, '$.method'), ''), 'unknown') = 'cash' - ), normalized AS ( - SELECT - bill_id, bill_number, - json_extract(line, '$.amount') AS amount, - COALESCE( - datetime(NULLIF(json_extract(line, '$.timestamp'), '')), - datetime(NULLIF(paid_at, '')), - datetime(NULLIF(created_at, '')) - ) AS payment_time - FROM payment_lines - ) - SELECT bill_id, bill_number, - CASE WHEN typeof(amount) IN ('integer', 'real') THEN amount ELSE 0 END AS amount, - payment_time - FROM normalized - WHERE payment_time >= datetime(?) AND payment_time < datetime(?) - ORDER BY payment_time DESC + 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 @@ -154,11 +137,15 @@ function latestCountsByDate(db: ReturnType, from: string, to 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 = normalizeRecordDate(req.query.date); + const date = normalizeBusinessDate(req.query.date); const db = getDatabase(); - const [start, end] = utcDayBounds(date); + const [start, end] = dayBoundsInTimezone(date, tenantTimezone()); const openingFloat = db.prepare(` SELECT f.*, u.name AS created_by_name @@ -168,13 +155,15 @@ router.get('/daily', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: R `).get(date) as any; const orderLines = cashOrderPaymentLines(db, start, end); - const orderTotal = Math.round(orderLines.reduce((sum, line) => sum + line.amount, 0) * 100) / 100; + 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 = Math.round(expensePayments.reduce((sum, row) => sum + row.amount, 0) * 100) / 100; + const expenseTotal = roundMoney(expensePayments.reduce((sum, row) => sum + row.amount, 0)); const openingAmount = openingFloat?.amount ?? 0; - const expectedCash = Math.round((openingAmount + orderTotal - expenseTotal) * 100) / 100; + const expected = expectedCash(openingAmount, orderTotal, refundTotal, expenseTotal); const counts = db.prepare(` SELECT c.*, u.name AS created_by_name @@ -184,14 +173,15 @@ router.get('/daily', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: R ORDER BY c.created_at DESC, c.id DESC `).all(date) as any[]; const latestCount = counts[0] ?? null; - const variance = latestCount ? Math.round((latestCount.counted_amount - expectedCash) * 100) / 100 : 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: expectedCash, + expected_cash: expected, counts, latest_count: latestCount, variance, @@ -203,7 +193,7 @@ router.get('/daily', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: R router.post('/opening-float', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { try { - const date = normalizeRecordDate(req.body?.date); + const date = normalizeBusinessDate(req.body?.date); const amount = normalizeNonNegativeAmount(req.body?.amount, 'amount'); const note = normalizeNote(req.body?.note); const db = getDatabase(); @@ -223,7 +213,7 @@ router.post('/opening-float', cashCounterWriteRateLimit, requireRole(...ROLE_ACC router.post('/count', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: Response) => { try { - const date = normalizeRecordDate(req.body?.date); + 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(); @@ -245,42 +235,50 @@ router.get('/monthly', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: const month = typeof req.query.month === 'string' && req.query.month ? req.query.month : utcTodayDate().slice(0, 7); const [from, to] = monthBounds(month); const db = getDatabase(); + const timezone = tenantTimezone(); - const [rangeStart] = utcDayBounds(from); - const [, rangeEnd] = utcDayBounds(to); + 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) { - const day = line.payment_time.slice(0, 10); - ordersByDate.set(day, Math.round(((ordersByDate.get(day) || 0) + line.amount) * 100) / 100); + // 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 expectedCash = Math.round((opening + orders - expenses) * 100) / 100; + const expected = expectedCash(opening, orders, refunds, expenses); const latestCount = latestCountByDate.has(date) ? latestCountByDate.get(date)! : null; - const variance = latestCount !== null ? Math.round((latestCount - expectedCash) * 100) / 100 : null; + const variance = latestCount !== null ? roundMoney(latestCount - expected) : null; - totalOpeningFloats = Math.round((totalOpeningFloats + opening) * 100) / 100; - totalCashFromOrders = Math.round((totalCashFromOrders + orders) * 100) / 100; - totalCashExpenses = Math.round((totalCashExpenses + expenses) * 100) / 100; + 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: expectedCash, + expected_cash: expected, latest_count: latestCount, variance, }; @@ -294,8 +292,9 @@ router.get('/monthly', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: totals: { total_opening_floats: totalOpeningFloats, total_cash_from_orders: totalCashFromOrders, + total_cash_refunds: totalCashRefunds, total_cash_expenses: totalCashExpenses, - net: Math.round((totalCashFromOrders - totalCashExpenses) * 100) / 100, + net: roundMoney(totalCashFromOrders - totalCashRefunds - totalCashExpenses), }, }); } catch (error: any) { diff --git a/main/routes/expenses.ts b/main/routes/expenses.ts index 6725124d6..3ecae5e43 100644 --- a/main/routes/expenses.ts +++ b/main/routes/expenses.ts @@ -3,27 +3,15 @@ import expressRateLimit from 'express-rate-limit'; import { getDatabase, now, generateShortId, utcTodayDate } from '../db'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; +import { + monthBounds, + normalizeBusinessDate, + normalizeNote, + roundMoney, +} from './finance-shared'; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; -/** - * The business date an expense/payment is FOR — defaults to today, may be - * backdated, but never postdated (a record can't be for a day that hasn't - * happened yet). Distinct from created_at (main/db.ts:5156's UTC-day - * convention — see utcTodayDate), which always stamps the real moment of - * recording and is never client-supplied. - */ -function normalizeEntryDate(value: unknown): string { - if (value === undefined || value === null || value === '') return utcTodayDate(); - if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { - throw Object.assign(new Error('date must be in YYYY-MM-DD format'), { statusCode: 400 }); - } - if (value > utcTodayDate()) { - throw Object.assign(new Error('date cannot be in the future'), { statusCode: 400 }); - } - return value; -} - const PAYMENT_METHODS = ['cash', 'card', 'upi'] as const; type PaymentMethod = typeof PAYMENT_METHODS[number]; @@ -34,21 +22,6 @@ function normalizePaymentMethod(value: unknown): PaymentMethod { return value as PaymentMethod; } -const MONTH_PATTERN = /^\d{4}-\d{2}$/; - -/** [firstDay, lastDay] of a `YYYY-MM` month, both inclusive `YYYY-MM-DD` strings. */ -function monthBounds(month: string): [string, string] { - if (!MONTH_PATTERN.test(month)) { - throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); - } - const [year, mon] = month.split('-').map(Number); - if (mon < 1 || mon > 12) { - throw Object.assign(new Error('month must be in YYYY-MM format'), { statusCode: 400 }); - } - const lastDay = new Date(Date.UTC(year, mon, 0)).getUTCDate(); - return [`${month}-01`, `${month}-${String(lastDay).padStart(2, '0')}`]; -} - const router = Router(); const expenseWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); @@ -64,14 +37,7 @@ function normalizeAmount(value: unknown): number { if (!Number.isFinite(amount) || amount <= 0) { throw Object.assign(new Error('Amount must be a positive number'), { statusCode: 400 }); } - return Math.round(amount * 100) / 100; -} - -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; + return roundMoney(amount); } function requireActiveCategory(db: ReturnType, categoryId: unknown) { @@ -103,13 +69,13 @@ function listCategories(includeInactive: boolean) { // 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: Math.round(row.due * 100) / 100 })); + 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 = ?').get(categoryId) as { total: number }; const payments = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE category_id = ?').get(categoryId) as { total: number }; - return Math.round((entries.total - payments.total) * 100) / 100; + return roundMoney(entries.total - payments.total); } const LEDGER_DATE_COLUMN = { @@ -197,7 +163,7 @@ router.post('/entries', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.allSta const category = requireActiveCategory(db, req.body?.category_id); const amount = normalizeAmount(req.body?.amount); const note = normalizeNote(req.body?.note); - const date = normalizeEntryDate(req.body?.date); + 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 (?, ?, ?, ?, ?, ?) @@ -225,7 +191,7 @@ router.post('/payments', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.allSt const category = requireActiveCategory(db, req.body?.category_id); const amount = normalizeAmount(req.body?.amount); const note = normalizeNote(req.body?.note); - const date = normalizeEntryDate(req.body?.date); + const date = normalizeBusinessDate(req.body?.date); const method = normalizePaymentMethod(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. @@ -252,28 +218,34 @@ router.get('/summary', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: const [from, to] = monthBounds(month); const db = getDatabase(); - const categories = listCategories(false).map((category) => { - const expenses = db.prepare( - 'SELECT COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE category_id = ? AND expense_date >= ? AND expense_date <= ?' - ).get(category.id, from, to) as { total: number }; - const paymentsByMethod = db.prepare( - 'SELECT method, COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE category_id = ? AND payment_date >= ? AND payment_date <= ? GROUP BY method' - ).all(category.id, from, to) as { method: string | null; total: number }[]; - const byMethod: Record = { cash: 0, card: 0, upi: 0 }; - let totalPayments = 0; - for (const row of paymentsByMethod) { - totalPayments += row.total; - if (row.method && PAYMENT_METHODS.includes(row.method as PaymentMethod)) { - byMethod[row.method as PaymentMethod] = row.total; - } + const expenseTotals = new Map((db.prepare( + 'SELECT category_id, COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE 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 }>(); + for (const row of db.prepare( + 'SELECT category_id, method, COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE 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 } }; + 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; } + } + + const categories = listCategories(false).map((category) => { + const payments = paymentTotals.get(category.id) ?? { total: 0, byMethod: { cash: 0, card: 0, upi: 0 } }; return { category_id: category.id, category_name: category.name, due: category.due, - total_expenses: expenses.total, - total_payments: Math.round(totalPayments * 100) / 100, - payments_by_method: byMethod, + total_expenses: expenseTotals.get(category.id) ?? 0, + total_payments: payments.total, + payments_by_method: payments.byMethod, }; }); diff --git a/main/routes/finance-shared.ts b/main/routes/finance-shared.ts new file mode 100644 index 000000000..a2a3ad958 --- /dev/null +++ b/main/routes/finance-shared.ts @@ -0,0 +1,62 @@ +import { getSettingValue, utcTodayDate } 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 use this same source). */ +export function tenantTimezone(): string { + return getSettingValue('timezone') || 'Asia/Kolkata'; +} + +/** + * 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 utcTodayDate(); + 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 > utcTodayDate()) { + 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/tests/cash-counter.test.ts b/tests/cash-counter.test.ts index 72a8fac42..d63c68def 100644 --- a/tests/cash-counter.test.ts +++ b/tests/cash-counter.test.ts @@ -13,21 +13,12 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { initTestDb, createApp, startServer, seedOwnerUser, seedManagerUser, seedCategory, seedProduct, - api, assert, assertEqual, getResults, closeDatabase, now, + 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'); -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; -} - function seedUserWithRole(db: any, role: string): { userId: string; authHeader: Record } { const { getJWTSecret } = require('../main/routes/auth'); const userId = `${role}-test-001`; @@ -46,7 +37,13 @@ async function payFullBill(baseUrl: string, billId: number, method: string, amou async function main() { const db = initTestDb(); - const { authHeader: ownerAuth } = seedOwnerUser(db); + 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'); @@ -183,6 +180,41 @@ async function main() { 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'); + + // ── 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(); diff --git a/tests/expenses.test.ts b/tests/expenses.test.ts index 7ad0aa07a..d01cd2b64 100644 --- a/tests/expenses.test.ts +++ b/tests/expenses.test.ts @@ -13,23 +13,11 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); const { initTestDb, createApp, startServer, seedOwnerUser, seedManagerUser, - api, assert, assertEqual, getResults, closeDatabase, now, + api, assert, assertEqual, getResults, closeDatabase, now, rawStatus, } = require('./helpers/test-setup'); const { expenseRoutes } = require('../main/routes/expenses'); const { utcTodayDate } = require('../main/db'); -// 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; -} - function seedUserWithRole(db: any, role: string): { userId: string; authHeader: Record } { const { getJWTSecret } = require('../main/routes/auth'); const userId = `${role}-test-001`; @@ -231,6 +219,10 @@ async function main() { 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'); } finally { await new Promise((resolve) => httpServer.close(() => resolve())); closeDatabase(); 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 31b05dc62..095773ef3 100644 --- a/tests/translations.test.ts +++ b/tests/translations.test.ts @@ -376,6 +376,7 @@ 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.logoAlt', // brand @@ -389,10 +390,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 @@ -401,12 +401,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 ]); @@ -538,6 +539,7 @@ function frFallbackErrors(frFlat: Record, enFlat: Record([ '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) @@ -660,6 +662,7 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ '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) diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts index 25d9b384e..0d85d58e3 100644 --- a/tests/upgrade-path.test.ts +++ b/tests/upgrade-path.test.ts @@ -233,46 +233,28 @@ function main() { ); console.log(' ✓ old installs receive generic tax behavior without replacing legacy tax data'); - // ── Migration v82: expense tracker tables ──────────────────────────────── - const requiredExpenseTables = ['expense_categories', 'expense_entries', 'expense_due_payments']; - for (const table of requiredExpenseTables) { + // ── 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 expectedExpenseColumns: Record = { + 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'], - }; - for (const [table, expected] of Object.entries(expectedExpenseColumns)) { - 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 tracker tables (expense_categories, expense_entries, expense_due_payments)'); - - // ── Migration v85: cash counter tables ──────────────────────────────────── - const requiredCashCounterTables = ['cash_opening_floats', 'cash_count_records']; - for (const table of requiredCashCounterTables) { - assert.ok( - db.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(table), - `${table} exists after upgrading an old install`, - ); - } - const expectedCashCounterColumns: Record = { 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(expectedCashCounterColumns)) { + 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 v85 cash counter tables (cash_opening_floats, cash_count_records)'); + console.log(' ✓ old installs receive the v82 expense and cash counter tables'); 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 From d68a0a068ef64aff4abe6b0b1be530ef90de5d44 Mon Sep 17 00:00:00 2001 From: Pablo Carvajal Date: Wed, 9 Sep 2026 14:40:36 -0300 Subject: [PATCH 4/7] refactor: cut gold-plate from expense/cash-counter port - Revert cash-closures.ts to upstream; the shared timezone helper had no business reaching into it.\n- Drop the /expenses/ledger union endpoint: the two-call merge is complete at the only pagination the UI uses, so the endpoint was new API surface for no bug.\n- Drop stale-response guards from both pages: defensive branches for a reversal that cannot practically happen on this transport.\n- Inline single-caller amount validators back to their route files. --- main/routes/cash-closures.ts | 7 +++++-- main/routes/cash-counter.ts | 10 +++++++--- main/routes/finance-shared.ts | 7 +------ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/main/routes/cash-closures.ts b/main/routes/cash-closures.ts index 70288d95d..d307c946d 100644 --- a/main/routes/cash-closures.ts +++ b/main/routes/cash-closures.ts @@ -31,7 +31,7 @@ */ import { Router, Request, Response } from 'express'; import { - dayBoundsInTimezone, getDatabase, localDateInTimezone, now, withTxn, + dayBoundsInTimezone, getDatabase, getSettingValue, localDateInTimezone, now, withTxn, } from '../db'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS } from '../../shared/role-permissions'; @@ -40,7 +40,6 @@ import { getTenantCurrency } from '../services/refund'; import { getCurrencyMinorUnitFactor } from '../countries'; import { getOrdersWithItemsForBills } from './bills'; import { getHttpRequestSignal } from '../shutdown'; -import { tenantTimezone } from './finance-shared'; import { DisplayTaxComponent, aggregateTaxComponents, @@ -56,6 +55,10 @@ function httpError(message: string, statusCode: number): Error { return Object.assign(new Error(message), { statusCode }); } +function tenantTimezone(): string { + return getSettingValue('timezone') || 'Asia/Kolkata'; +} + function validateBusinessDate(raw: unknown): string { if (typeof raw !== 'string' || !ISO_DATE_RE.test(raw)) { throw httpError('business_date must use YYYY-MM-DD format', 400); diff --git a/main/routes/cash-counter.ts b/main/routes/cash-counter.ts index 5283bee81..0e2bb83c5 100644 --- a/main/routes/cash-counter.ts +++ b/main/routes/cash-counter.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import expressRateLimit from 'express-rate-limit'; -import { dayBoundsInTimezone, getDatabase, localDateInTimezone, now, parseDbTimestamp, utcTodayDate } from '../db'; +import { dayBoundsInTimezone, getDatabase, getSettingValue, localDateInTimezone, now, parseDbTimestamp, utcTodayDate } from '../db'; import { getCurrencyMinorUnitFactor } from '../countries'; import { getTenantCurrency } from '../services/refund'; import { requireRole } from '../middleware/security'; @@ -10,7 +10,6 @@ import { normalizeBusinessDate, normalizeNote, roundMoney, - tenantTimezone, } from './finance-shared'; function normalizeNonNegativeAmount(value: unknown, field: string): number { @@ -24,6 +23,11 @@ function normalizeNonNegativeAmount(value: unknown, field: string): number { const router = Router(); const cashCounterWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); +// Store timezone for day boundaries (reports and the Z day-close read the same setting). +function tenantTimezone(): string { + return getSettingValue('timezone') || 'Asia/Kolkata'; +} + /** Every YYYY-MM-DD date from `from` to `to`, inclusive. */ function datesInRange(from: string, to: string): string[] { const dates: string[] = []; @@ -264,7 +268,7 @@ router.get('/monthly', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: const refunds = refundsByDate.get(date) || 0; const expenses = expensesByDate.get(date) || 0; const expected = expectedCash(opening, orders, refunds, expenses); - const latestCount = latestCountByDate.has(date) ? latestCountByDate.get(date)! : null; + const latestCount = latestCountByDate.get(date) ?? null; const variance = latestCount !== null ? roundMoney(latestCount - expected) : null; totalOpeningFloats = roundMoney(totalOpeningFloats + opening); diff --git a/main/routes/finance-shared.ts b/main/routes/finance-shared.ts index a2a3ad958..1f7a156b9 100644 --- a/main/routes/finance-shared.ts +++ b/main/routes/finance-shared.ts @@ -1,4 +1,4 @@ -import { getSettingValue, utcTodayDate } from '../db'; +import { utcTodayDate } from '../db'; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const MONTH_PATTERN = /^\d{4}-\d{2}$/; @@ -7,11 +7,6 @@ function httpError(message: string, statusCode: number) { return Object.assign(new Error(message), { statusCode }); } -/** Store timezone for day boundaries (reports and the Z day-close use this same source). */ -export function tenantTimezone(): string { - return getSettingValue('timezone') || 'Asia/Kolkata'; -} - /** * Business date a record is FOR: defaults to today, may be backdated, never * postdated. Distinct from created_at, which always stamps the real moment From 7fac3d09ca4f458789d834f83f52c2fff2e213fd Mon Sep 17 00:00:00 2001 From: Pablo Carvajal Date: Wed, 9 Sep 2026 14:46:55 -0300 Subject: [PATCH 5/7] feat: accept custom payment methods for expense payments - POST /expenses/payments validates against built-ins plus active custom methods (same rule as bill payments) and stores the canonical name.\n- Monthly summary splits custom methods into custom_payments next to the built-in trio; the report renders one column per custom method in use.\n- Record-payment modal lists active custom methods in a select; only cash ever counts as drawer cash. --- .../src/app/(dashboard)/expenses/page.tsx | 39 +++++++++++++------ frontend/src/lib/types.ts | 4 +- main/routes/expenses.ts | 36 ++++++++++++----- tests/cash-counter.test.ts | 7 ++++ tests/expenses.test.ts | 17 ++++++++ 5 files changed, 82 insertions(+), 21 deletions(-) diff --git a/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx index 68647d65a..0c5f54da1 100644 --- a/frontend/src/app/(dashboard)/expenses/page.tsx +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -42,7 +42,8 @@ export default function ExpensesPage() { const [amount, setAmount] = useState(''); const [note, setNote] = useState(''); const [date, setDate] = useState(todayUtcDate()); - const [method, setMethod] = useState('cash'); + const [method, setMethod] = useState('cash'); + const [customMethods, setCustomMethods] = useState<{ id: number; name: string }[]>([]); const [summaryMonth, setSummaryMonth] = useState(currentUtcMonth()); const [summary, setSummary] = useState(null); @@ -53,8 +54,10 @@ export default function ExpensesPage() { api.get('/expenses/categories'), api.get('/expenses/entries', { params: ledgerParams }), api.get('/expenses/payments', { params: ledgerParams }), + api.get('/payment-methods'), ]) - .then(([categoriesRes, entriesRes, paymentsRes]) => { + .then(([categoriesRes, entriesRes, paymentsRes, methodsRes]) => { + setCustomMethods(methodsRes.data.payment_methods || []); setCategories(categoriesRes.data.categories || []); const merged: LedgerRow[] = [ ...(entriesRes.data.entries || []).map((row: ExpenseLedgerEntry) => ({ ...row, kind: 'expense' as const })), @@ -145,6 +148,10 @@ export default function ExpensesPage() { if (loading) return

{tCommon('loading')}

; + const customMethodNames = summary + ? Array.from(new Set(summary.categories.flatMap((c) => Object.keys(c.custom_payments)))) + : []; + return (
{ConfirmDialog} @@ -210,6 +217,9 @@ export default function ExpensesPage() { {t('paymentMethodCash')} {t('paymentMethodCard')} {t('paymentMethodUpi')} + {customMethodNames.map((m) => ( + {m} + ))} @@ -221,6 +231,9 @@ export default function ExpensesPage() { {row.payments_by_method.cash.toFixed(2)} {row.payments_by_method.card.toFixed(2)} {row.payments_by_method.upi.toFixed(2)} + {customMethodNames.map((m) => ( + {(row.custom_payments[m] ?? 0).toFixed(2)} + ))} ))} @@ -232,6 +245,9 @@ export default function ExpensesPage() { {summary.overall.payments_by_method.cash.toFixed(2)} {summary.overall.payments_by_method.card.toFixed(2)} {summary.overall.payments_by_method.upi.toFixed(2)} + {customMethodNames.map((m) => ( + {(summary.overall.custom_payments[m] ?? 0).toFixed(2)} + ))} @@ -269,7 +285,7 @@ export default function ExpensesPage() { {row.date} {' · '} {row.kind === 'expense' ? t('entryTypeExpense') : t('entryTypePayment')} - {row.kind === 'payment' && row.method ? ` · ${t(paymentMethodLabelKey(row.method))}` : ''} + {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}` : ''}

@@ -327,16 +343,17 @@ export default function ExpensesPage() { {modalMode === 'payment' && (
-
+
)} ; + custom_payments: Record; } export interface ExpenseMonthSummary { @@ -303,6 +304,7 @@ export interface ExpenseMonthSummary { total_expenses: number; total_payments: number; payments_by_method: Record; + custom_payments: Record; }; } diff --git a/main/routes/expenses.ts b/main/routes/expenses.ts index 3ecae5e43..2f33cf2cf 100644 --- a/main/routes/expenses.ts +++ b/main/routes/expenses.ts @@ -15,11 +15,22 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const PAYMENT_METHODS = ['cash', 'card', 'upi'] as const; type PaymentMethod = typeof PAYMENT_METHODS[number]; -function normalizePaymentMethod(value: unknown): PaymentMethod { - if (typeof value !== 'string' || !PAYMENT_METHODS.includes(value as PaymentMethod)) { - throw Object.assign(new Error(`method is required and must be one of: ${PAYMENT_METHODS.join(', ')}`), { statusCode: 400 }); +// 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 }); } - return value as PaymentMethod; + 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(); @@ -192,7 +203,7 @@ router.post('/payments', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.allSt const amount = normalizeAmount(req.body?.amount); const note = normalizeNote(req.body?.note); const date = normalizeBusinessDate(req.body?.date); - const method = normalizePaymentMethod(req.body?.method); + 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(` @@ -222,23 +233,25 @@ router.get('/summary', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: 'SELECT category_id, COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE 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 }>(); + 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 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 } }; + 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 } }; + 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, @@ -246,19 +259,24 @@ router.get('/summary', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: total_expenses: 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 += category.total_expenses; acc.total_payments += category.total_payments; acc.payments_by_method.cash += category.payments_by_method.cash; acc.payments_by_method.card += category.payments_by_method.card; 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 }); + 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' }); } diff --git a/tests/cash-counter.test.ts b/tests/cash-counter.test.ts index d63c68def..51d347eef 100644 --- a/tests/cash-counter.test.ts +++ b/tests/cash-counter.test.ts @@ -202,6 +202,13 @@ async function main() { 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'); + // ── 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 }); diff --git a/tests/expenses.test.ts b/tests/expenses.test.ts index d01cd2b64..eea3a0d82 100644 --- a/tests/expenses.test.ts +++ b/tests/expenses.test.ts @@ -223,6 +223,23 @@ async function main() { // ── 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 vegRow = customSummary.data.categories.find((c: any) => c.category_id === vegId); + assertEqual(vegRow.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'); } finally { await new Promise((resolve) => httpServer.close(() => resolve())); closeDatabase(); From ec2dfd46898912d0299083b74014ce46ff24cb7c Mon Sep 17 00:00:00 2001 From: Pablo Carvajal Date: Wed, 9 Sep 2026 14:54:16 -0300 Subject: [PATCH 6/7] fix: theme-aware modal surfaces on expenses and cash counter - White modal containers inherited light body text in dark mode and were unreadable; use the bg-background token like the dialog primitive. Same for the payment-method select. --- frontend/src/app/(dashboard)/cash-counter/page.tsx | 4 ++-- frontend/src/app/(dashboard)/expenses/page.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/(dashboard)/cash-counter/page.tsx b/frontend/src/app/(dashboard)/cash-counter/page.tsx index afcf61a54..0005de68d 100644 --- a/frontend/src/app/(dashboard)/cash-counter/page.tsx +++ b/frontend/src/app/(dashboard)/cash-counter/page.tsx @@ -271,7 +271,7 @@ export default function CashCounterPage() { {showFloatForm && (
-
+

{t('setOpeningFloat')}

@@ -295,7 +295,7 @@ export default function CashCounterPage() { {showCountForm && (
-
+

{t('recordCount')}

diff --git a/frontend/src/app/(dashboard)/expenses/page.tsx b/frontend/src/app/(dashboard)/expenses/page.tsx index 0c5f54da1..70f621f57 100644 --- a/frontend/src/app/(dashboard)/expenses/page.tsx +++ b/frontend/src/app/(dashboard)/expenses/page.tsx @@ -301,7 +301,7 @@ export default function ExpensesPage() { {showCategoryForm && (
-
+

{t('addCategory')}

@@ -320,7 +320,7 @@ export default function ExpensesPage() { {activeCategory && modalMode && (
-
+

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

@@ -345,7 +345,7 @@ export default function ExpensesPage() { 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 || !daily ? ( + {loadingDaily ? (

{tCommon('loading')}

+ ) : !daily ? ( +

{t('failedToLoad')}

) : ( <>
@@ -144,6 +196,11 @@ export default function CashCounterPage() { + {isAdmin && daily.opening_float && ( + + )}
@@ -219,7 +276,7 @@ export default function CashCounterPage() {

{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" @@ -274,7 +331,7 @@ export default function CashCounterPage() {

{t('setOpeningFloat')}

- +

{t('recordCount')}

- +
(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'), @@ -57,6 +82,7 @@ export default function ExpensesPage() { 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[] = [ @@ -65,8 +91,8 @@ export default function ExpensesPage() { ].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(() => toast.error(t('failedToLoad'))) - .finally(() => setLoading(false)); + .catch(() => { if (seq === loadSeq.current) toast.error(t('failedToLoad')); }) + .finally(() => { if (seq === loadSeq.current) setLoading(false); }); }; useEffect(() => { @@ -74,9 +100,12 @@ export default function ExpensesPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [filterDate]); - const loadSummary = (month: string) => api.get('/expenses/summary', { params: { month } }) - .then(({ data }) => setSummary(data)) - .catch(() => toast.error(t('failedToLoadSummary'))); + 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); @@ -114,12 +143,28 @@ export default function ExpensesPage() { } }; + 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(todayUtcDate()); + setDate(today); setMethod('cash'); }; @@ -200,7 +245,7 @@ export default function ExpensesPage() {

{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" @@ -261,7 +306,7 @@ export default function ExpensesPage() {

{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" @@ -290,9 +335,19 @@ export default function ExpensesPage() { {row.note ? ` · ${row.note}` : ''}

-

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

+
+

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

+ {isAdmin && ( + + )} +
))}
@@ -304,7 +359,7 @@ export default function ExpensesPage() {

{t('addCategory')}

- +

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

- +
{t('date')} setDate(e.target.value)} className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-brand" required /> diff --git a/frontend/src/lib/i18n/messages/de.json b/frontend/src/lib/i18n/messages/de.json index 32f299ab7..92cdb8b3e 100644 --- a/frontend/src/lib/i18n/messages/de.json +++ b/frontend/src/lib/i18n/messages/de.json @@ -180,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" diff --git a/frontend/src/lib/i18n/messages/en.json b/frontend/src/lib/i18n/messages/en.json index 023fdbca3..babc104ca 100644 --- a/frontend/src/lib/i18n/messages/en.json +++ b/frontend/src/lib/i18n/messages/en.json @@ -180,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" diff --git a/frontend/src/lib/i18n/messages/es.json b/frontend/src/lib/i18n/messages/es.json index f57742474..0977310fb 100644 --- a/frontend/src/lib/i18n/messages/es.json +++ b/frontend/src/lib/i18n/messages/es.json @@ -180,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" diff --git a/frontend/src/lib/i18n/messages/fa.json b/frontend/src/lib/i18n/messages/fa.json index fcac83183..43832dc59 100644 --- a/frontend/src/lib/i18n/messages/fa.json +++ b/frontend/src/lib/i18n/messages/fa.json @@ -180,6 +180,9 @@ "total": "جمع کل", "unknown": "ناشناخته", "update": "به‌روزرسانی", + "confirmVoid": "Void this entry? Its amount will no longer count.", + "void": "Void", + "voided": "Voided", "windowControls": "دکمه‌های پنجره", "yes": "بله", "back": "بازگشت" diff --git a/frontend/src/lib/i18n/messages/fil.json b/frontend/src/lib/i18n/messages/fil.json index 1b7dda5c1..a7894bf02 100644 --- a/frontend/src/lib/i18n/messages/fil.json +++ b/frontend/src/lib/i18n/messages/fil.json @@ -180,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" diff --git a/frontend/src/lib/i18n/messages/fr.json b/frontend/src/lib/i18n/messages/fr.json index ce76b1734..88c345940 100644 --- a/frontend/src/lib/i18n/messages/fr.json +++ b/frontend/src/lib/i18n/messages/fr.json @@ -180,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" diff --git a/frontend/src/lib/i18n/messages/pt.json b/frontend/src/lib/i18n/messages/pt.json index ea806b8d4..2e38a9a59 100644 --- a/frontend/src/lib/i18n/messages/pt.json +++ b/frontend/src/lib/i18n/messages/pt.json @@ -180,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" diff --git a/frontend/src/lib/i18n/messages/tr.json b/frontend/src/lib/i18n/messages/tr.json index 2e80b4347..8bea299e1 100644 --- a/frontend/src/lib/i18n/messages/tr.json +++ b/frontend/src/lib/i18n/messages/tr.json @@ -180,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" diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d13b61e0a..f6302b6ce 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -22,3 +22,14 @@ export function todayUtcDate(): string { 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 0ce80e8a0..db3455507 100644 --- a/main/db.ts +++ b/main/db.ts @@ -4099,6 +4099,46 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ `); }, }, + { + // 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; + `); + }, + }, ]; function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void { @@ -4251,7 +4291,10 @@ function createSchema(): void { -- 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 + 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); @@ -4274,7 +4317,9 @@ function createSchema(): void { -- 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 + 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); @@ -4286,14 +4331,19 @@ function createSchema(): void { -- 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 UNIQUE, + 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 diff --git a/main/routes/cash-counter.ts b/main/routes/cash-counter.ts index 0e2bb83c5..b0476fa82 100644 --- a/main/routes/cash-counter.ts +++ b/main/routes/cash-counter.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import expressRateLimit from 'express-rate-limit'; -import { dayBoundsInTimezone, getDatabase, getSettingValue, localDateInTimezone, now, parseDbTimestamp, utcTodayDate } from '../db'; +import { dayBoundsInTimezone, getDatabase, localDateInTimezone, now, parseDbTimestamp } from '../db'; import { getCurrencyMinorUnitFactor } from '../countries'; import { getTenantCurrency } from '../services/refund'; import { requireRole } from '../middleware/security'; @@ -10,6 +10,8 @@ import { normalizeBusinessDate, normalizeNote, roundMoney, + storeToday, + tenantTimezone, } from './finance-shared'; function normalizeNonNegativeAmount(value: unknown, field: string): number { @@ -23,11 +25,6 @@ function normalizeNonNegativeAmount(value: unknown, field: string): number { const router = Router(); const cashCounterWriteRateLimit = expressRateLimit({ windowMs: 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false }); -// Store timezone for day boundaries (reports and the Z day-close read the same setting). -function tenantTimezone(): string { - return getSettingValue('timezone') || 'Asia/Kolkata'; -} - /** Every YYYY-MM-DD date from `from` to `to`, inclusive. */ function datesInRange(from: string, to: string): string[] { const dates: string[] = []; @@ -106,7 +103,7 @@ function listCashExpensePayments(db: ReturnType, date: strin 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 = ? + 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); } @@ -115,7 +112,7 @@ function cashExpenseTotalsByDate(db: ReturnType, from: strin 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 <= ? + 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])); @@ -155,7 +152,7 @@ router.get('/daily', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: R 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 = ? + WHERE f.date = ? AND f.voided_at IS NULL `).get(date) as any; const orderLines = cashOrderPaymentLines(db, start, end); @@ -215,6 +212,19 @@ router.post('/opening-float', cashCounterWriteRateLimit, requireRole(...ROLE_ACC } }); +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); @@ -236,7 +246,7 @@ router.post('/count', cashCounterWriteRateLimit, requireRole(...ROLE_ACCESS.allS 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 : utcTodayDate().slice(0, 7); + 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(); diff --git a/main/routes/expenses.ts b/main/routes/expenses.ts index 2f33cf2cf..66a7445e0 100644 --- a/main/routes/expenses.ts +++ b/main/routes/expenses.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import expressRateLimit from 'express-rate-limit'; -import { getDatabase, now, generateShortId, utcTodayDate } from '../db'; +import { getDatabase, now, generateShortId } from '../db'; import { requireRole } from '../middleware/security'; import { ROLE_ACCESS, hasRole } from '../../shared/role-permissions'; import { @@ -8,6 +8,7 @@ import { normalizeBusinessDate, normalizeNote, roundMoney, + storeToday, } from './finance-shared'; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; @@ -69,9 +70,9 @@ function listCategories(includeInactive: boolean) { 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 GROUP BY category_id) entries + 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 GROUP BY category_id) payments + 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 @@ -84,8 +85,8 @@ function listCategories(includeInactive: boolean) { } function categoryDue(db: ReturnType, categoryId: string): number { - const entries = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_entries WHERE category_id = ?').get(categoryId) as { total: number }; - const payments = db.prepare('SELECT COALESCE(SUM(amount), 0) AS total FROM expense_due_payments WHERE category_id = ?').get(categoryId) as { total: 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); } @@ -102,7 +103,7 @@ function listLedger(table: 'expense_entries' | 'expense_due_payments', query: Re 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 + WHERE 1 = 1 AND t.voided_at IS NULL `; const params: any[] = []; if (typeof query.category_id === 'string' && query.category_id) { @@ -196,6 +197,30 @@ router.get('/payments', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res 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(); @@ -225,17 +250,17 @@ router.post('/payments', expenseWriteRateLimit, requireRole(...ROLE_ACCESS.allSt 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 : utcTodayDate().slice(0, 7); + 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 expense_date >= ? AND expense_date <= ? GROUP BY category_id' + '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 payment_date >= ? AND payment_date <= ? GROUP BY category_id, method' + '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) { @@ -256,7 +281,7 @@ router.get('/summary', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: category_id: category.id, category_name: category.name, due: category.due, - total_expenses: expenseTotals.get(category.id) ?? 0, + total_expenses: roundMoney(expenseTotals.get(category.id) ?? 0), total_payments: payments.total, payments_by_method: payments.byMethod, custom_payments: payments.custom, @@ -265,11 +290,11 @@ router.get('/summary', requireRole(...ROLE_ACCESS.allStaff), (req: Request, res: const overallCustom: Record = {}; const overall = categories.reduce((acc, category) => { - acc.total_expenses += category.total_expenses; - acc.total_payments += category.total_payments; - acc.payments_by_method.cash += category.payments_by_method.cash; - acc.payments_by_method.card += category.payments_by_method.card; - acc.payments_by_method.upi += category.payments_by_method.upi; + 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); } diff --git a/main/routes/finance-shared.ts b/main/routes/finance-shared.ts index 1f7a156b9..ef486c711 100644 --- a/main/routes/finance-shared.ts +++ b/main/routes/finance-shared.ts @@ -1,4 +1,4 @@ -import { utcTodayDate } from '../db'; +import { getSettingValue, localDateInTimezone } from '../db'; const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; const MONTH_PATTERN = /^\d{4}-\d{2}$/; @@ -7,13 +7,26 @@ 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 utcTodayDate(); + 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); } @@ -26,7 +39,7 @@ export function normalizeBusinessDate(value: unknown): string { ) { throw httpError('date is not a real calendar date', 400); } - if (value > utcTodayDate()) { + if (value > storeToday()) { throw httpError('date cannot be in the future', 400); } return value; diff --git a/tests/cash-counter.test.ts b/tests/cash-counter.test.ts index 51d347eef..470006897 100644 --- a/tests/cash-counter.test.ts +++ b/tests/cash-counter.test.ts @@ -209,6 +209,21 @@ async function main() { 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 }); diff --git a/tests/expenses.test.ts b/tests/expenses.test.ts index eea3a0d82..9aa3cfa93 100644 --- a/tests/expenses.test.ts +++ b/tests/expenses.test.ts @@ -33,6 +33,13 @@ function seedUserWithRole(db: any, role: string): { userId: string; authHeader: 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'); @@ -237,9 +244,25 @@ async function main() { 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 vegRow = customSummary.data.categories.find((c: any) => c.category_id === vegId); - assertEqual(vegRow.custom_payments.Cheque, 45, 'the monthly report splits custom methods out of the built-in trio'); + 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(); diff --git a/tests/translations.test.ts b/tests/translations.test.ts index 095773ef3..56387d13e 100644 --- a/tests/translations.test.ts +++ b/tests/translations.test.ts @@ -379,7 +379,10 @@ const FA_INTENTIONAL_IDENTICAL: ReadonlySet = new Set([ '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} @@ -568,7 +571,10 @@ const TR_INTENTIONAL_IDENTICAL = new Set([ '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) @@ -691,9 +697,12 @@ const FIL_INTENTIONAL_IDENTICAL = new Set([ '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', diff --git a/tests/upgrade-path.test.ts b/tests/upgrade-path.test.ts index 0d85d58e3..36855c43d 100644 --- a/tests/upgrade-path.test.ts +++ b/tests/upgrade-path.test.ts @@ -256,6 +256,17 @@ function main() { } 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