From 5bdd898c38402d8f0c79bd57b4cb479a48f4a0cf Mon Sep 17 00:00:00 2001
From: MohamedAliSmk
Date: Mon, 15 Jun 2026 13:58:27 +0300
Subject: [PATCH 1/3] feat(pos): implement POS expense recording and summary
features
- Added ExpenseDialog component for recording expenses in the POS.
- Updated ShiftClosingDialog to display POS expenses summary and net cash impact.
- Enhanced InvoiceCart to include a button for recording POS expenses.
- Introduced new computed properties for managing expenses in the POS shift store.
- Updated translations to support new expense-related features.
---
POS/components.d.ts | 1 +
POS/src/components/ShiftClosingDialog.vue | 63 +++
POS/src/components/sale/ExpenseDialog.vue | 334 ++++++++++++++++
POS/src/components/sale/InvoiceCart.vue | 35 ++
POS/src/pages/POSSale.vue | 43 +++
POS/src/stores/posShift.js | 8 +
POS/src/stores/posUI.js | 3 +
pos_next/api/bootstrap.py | 2 +
pos_next/api/expenses.py | 364 ++++++++++++++++++
pos_next/api/test_expenses.py | 220 +++++++++++
pos_next/pos_next/custom/journal_entry.json | 95 +++++
pos_next/pos_next/custom/pos_profile.json | 128 ++++++
.../pos_closing_shift/pos_closing_shift.json | 22 ++
.../pos_closing_shift/pos_closing_shift.py | 31 ++
.../pos_closing_shift_expense.json | 55 +++
.../pos_closing_shift_expense.py | 8 +
.../pos_expense_report/pos_expense_report.js | 50 +++
.../pos_expense_report.json | 40 ++
.../pos_expense_report/pos_expense_report.py | 152 ++++++++
.../pos_next/workspace/posnext/posnext.json | 12 +-
pos_next/translations/ar.csv | 39 ++
21 files changed, 1704 insertions(+), 1 deletion(-)
create mode 100644 POS/src/components/sale/ExpenseDialog.vue
create mode 100644 pos_next/api/expenses.py
create mode 100644 pos_next/api/test_expenses.py
create mode 100644 pos_next/pos_next/custom/journal_entry.json
create mode 100644 pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.json
create mode 100644 pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.py
create mode 100644 pos_next/pos_next/report/pos_expense_report/pos_expense_report.js
create mode 100644 pos_next/pos_next/report/pos_expense_report/pos_expense_report.json
create mode 100644 pos_next/pos_next/report/pos_expense_report/pos_expense_report.py
diff --git a/POS/components.d.ts b/POS/components.d.ts
index 1f2e8b35f..7ee001c9c 100644
--- a/POS/components.d.ts
+++ b/POS/components.d.ts
@@ -20,6 +20,7 @@ declare module 'vue' {
CustomerDialog: typeof import('./src/components/sale/CustomerDialog.vue')['default']
DraftInvoicesDialog: typeof import('./src/components/sale/DraftInvoicesDialog.vue')['default']
EditItemDialog: typeof import('./src/components/sale/EditItemDialog.vue')['default']
+ ExpenseDialog: typeof import('./src/components/sale/ExpenseDialog.vue')['default']
InstallAppBadge: typeof import('./src/components/common/InstallAppBadge.vue')['default']
InvoiceCart: typeof import('./src/components/sale/InvoiceCart.vue')['default']
InvoiceDetailDialog: typeof import('./src/components/invoices/InvoiceDetailDialog.vue')['default']
diff --git a/POS/src/components/ShiftClosingDialog.vue b/POS/src/components/ShiftClosingDialog.vue
index 8f110ed2b..f564d32bc 100644
--- a/POS/src/components/ShiftClosingDialog.vue
+++ b/POS/src/components/ShiftClosingDialog.vue
@@ -44,6 +44,13 @@
{{ __('{0} returns', [closingData.returns_count]) }}
+
+
+
{{ __('POS Expenses') }}
+
-{{ formatCurrency(closingData.expenses_total) }}
+
{{ __('{0} expenses', [closingData.expenses_count]) }}
+
+
{{ __('Net Sales') }}
@@ -51,6 +58,13 @@
{{ __('After returns') }}
+
+
+
{{ __('Net Cash Impact') }}
+
{{ formatCurrency(netCashImpact) }}
+
{{ __('Sales - Returns - Expenses') }}
+
+
{{ __('Tax Collected') }}
@@ -193,6 +207,36 @@
+
+
+
+
{{ __('POS Expenses') }}
+
+ {{ __('{0} expenses totaling {1}', [closingData.expenses_count, formatCurrency(closingData.expenses_total)]) }}
+
+
+
+
+
+
+ | {{ __('Expense Account') }} |
+ {{ __('Amount') }} |
+ {{ __('Employee') }} |
+ {{ __('Remarks') }} |
+
+
+
+
+ | {{ expense.expense_account }} |
+ {{ formatCurrency(expense.amount) }} |
+ {{ expense.employee || __('N/A') }} |
+ {{ expense.remarks || __('N/A') }} |
+
+
+
+
+
+
+
+ {{ __('Expenses: -{0}', [formatCurrency(payment.expense_amount)]) }}
+
@@ -777,6 +827,19 @@ const hasReturns = computed(() => {
return (closingData.value.returns_count || 0) > 0
})
+const hasExpenses = computed(() => {
+ if (!closingData.value) return false
+ return (closingData.value.expenses_count || 0) > 0
+})
+
+const netCashImpact = computed(() => {
+ if (!closingData.value) return 0
+ const sales = Number.parseFloat(closingData.value.sales_total || closingData.value.grand_total || 0)
+ const returns = Number.parseFloat(closingData.value.returns_total || 0)
+ const expenses = Number.parseFloat(closingData.value.expenses_total || 0)
+ return sales - returns - expenses
+})
+
// Count of sales invoices (non-returns)
const salesInvoiceCount = computed(() => {
if (!closingData.value) return 0
diff --git a/POS/src/components/sale/ExpenseDialog.vue b/POS/src/components/sale/ExpenseDialog.vue
new file mode 100644
index 000000000..1e81589a5
--- /dev/null
+++ b/POS/src/components/sale/ExpenseDialog.vue
@@ -0,0 +1,334 @@
+
+
+
+
+
+
+
diff --git a/POS/src/components/sale/InvoiceCart.vue b/POS/src/components/sale/InvoiceCart.vue
index d91835154..24143d80b 100644
--- a/POS/src/components/sale/InvoiceCart.vue
+++ b/POS/src/components/sale/InvoiceCart.vue
@@ -680,6 +680,36 @@
}}
+
+
+
+
@@ -544,6 +566,15 @@
@return-created="handleReturnCreated"
/>
+
+
+
{
});
const canAccessShiftActions = computed(() => shiftStore.hasOpenShift);
+const canRecordPosExpense = computed(
+ () => canAccessShiftActions.value && shiftStore.allowPosExpense,
+);
/** Desk link only for users with the Nexus POS Manager role (from bootstrap API). */
const canSwitchToDesk = computed(() => Boolean(bootstrapStore.data?.can_switch_to_desk));
@@ -2366,6 +2401,14 @@ function openReturnDialog() {
uiStore.showReturnDialog = true;
}
+function openExpenseDialog() {
+ if (!canRecordPosExpense.value) {
+ return;
+ }
+
+ uiStore.showExpenseDialog = true;
+}
+
function switchToDesk() {
if (!canAccessShiftActions.value || !canSwitchToDesk.value || typeof window === "undefined") {
return;
diff --git a/POS/src/stores/posShift.js b/POS/src/stores/posShift.js
index d7851cb46..53741fd09 100644
--- a/POS/src/stores/posShift.js
+++ b/POS/src/stores/posShift.js
@@ -27,6 +27,12 @@ export const usePOSShiftStore = defineStore("posShift", () => {
const writeOffAccount = computed(() => currentProfile.value?.write_off_account)
const writeOffCostCenter = computed(() => currentProfile.value?.write_off_cost_center)
const writeOffLimit = computed(() => currentProfile.value?.write_off_limit || 0)
+ const allowPosExpense = computed(
+ () => Number(currentProfile.value?.posa_allow_pos_expense || 0) === 1,
+ )
+ const maximumExpenseAmount = computed(
+ () => Number.parseFloat(currentProfile.value?.posa_maximum_expense_amount) || 0,
+ )
// Actions
function updateShiftDuration() {
@@ -109,6 +115,8 @@ export const usePOSShiftStore = defineStore("posShift", () => {
writeOffAccount,
writeOffCostCenter,
writeOffLimit,
+ allowPosExpense,
+ maximumExpenseAmount,
// Actions
updateShiftDuration,
diff --git a/POS/src/stores/posUI.js b/POS/src/stores/posUI.js
index f34a5c445..c3fd487bf 100644
--- a/POS/src/stores/posUI.js
+++ b/POS/src/stores/posUI.js
@@ -17,6 +17,7 @@ export const usePOSUIStore = defineStore("posUI", () => {
const { isOpen: showCloseShiftDialog } = useDialog("closeShift")
const { isOpen: showDraftDialog } = useDialog("draft")
const { isOpen: showReturnDialog } = useDialog("return")
+ const { isOpen: showExpenseDialog } = useDialog("expense")
const { isOpen: showCouponDialog } = useDialog("coupon")
const { isOpen: showOffersDialog } = useDialog("offers")
const { isOpen: showBatchSerialDialog } = useDialog("batchSerial")
@@ -163,6 +164,7 @@ export const usePOSUIStore = defineStore("posUI", () => {
showCloseShiftDialog.value = false
showDraftDialog.value = false
showReturnDialog.value = false
+ showExpenseDialog.value = false
showCouponDialog.value = false
showOffersDialog.value = false
showBatchSerialDialog.value = false
@@ -187,6 +189,7 @@ export const usePOSUIStore = defineStore("posUI", () => {
showCloseShiftDialog,
showDraftDialog,
showReturnDialog,
+ showExpenseDialog,
showCouponDialog,
showOffersDialog,
showBatchSerialDialog,
diff --git a/pos_next/api/bootstrap.py b/pos_next/api/bootstrap.py
index c66df9300..b86526618 100644
--- a/pos_next/api/bootstrap.py
+++ b/pos_next/api/bootstrap.py
@@ -101,6 +101,8 @@ def get_initial_data():
"auto_print": pos_profile.get("print_receipt_on_order_complete", 0),
"country": pos_profile.get("country"),
"ignore_pricing_rule": pos_profile.ignore_pricing_rule or 0,
+ "posa_allow_pos_expense": pos_profile.get("posa_allow_pos_expense") or 0,
+ "posa_maximum_expense_amount": pos_profile.get("posa_maximum_expense_amount") or 0,
}
result["pos_settings"] = _get_pos_settings(pos_profile)
diff --git a/pos_next/api/expenses.py b/pos_next/api/expenses.py
new file mode 100644
index 000000000..b4ccb6e96
--- /dev/null
+++ b/pos_next/api/expenses.py
@@ -0,0 +1,364 @@
+# Copyright (c) 2026, BrainWise and contributors
+# For license information, please see license.txt
+
+"""
+POS Expense API
+Records expenses from active POS shifts as submitted Journal Entries.
+"""
+
+import frappe
+from frappe import _
+from frappe.utils import cint, cstr, flt, today
+
+
+@frappe.whitelist()
+def get_expense_dialog_data(pos_profile, pos_opening_shift):
+ """Return expense accounts and payment methods for the expense dialog."""
+ validate_pos_expense_enabled(pos_profile)
+ validate_open_shift(pos_opening_shift, pos_profile)
+
+ company = frappe.db.get_value("POS Profile", pos_profile, "company")
+ maximum_expense_amount = flt(
+ frappe.db.get_value("POS Profile", pos_profile, "posa_maximum_expense_amount")
+ )
+
+ from pos_next.api.pos_profile import get_payment_methods
+
+ return {
+ "expense_accounts": get_expense_accounts(company),
+ "payment_methods": get_payment_methods(pos_profile),
+ "employees": get_active_employees(company),
+ "maximum_expense_amount": maximum_expense_amount,
+ }
+
+
+@frappe.whitelist()
+def create_pos_expense(
+ pos_opening_shift,
+ pos_profile,
+ expense_account,
+ amount,
+ mode_of_payment,
+ employee=None,
+ remarks=None,
+):
+ """Create and submit a Journal Entry for a POS expense."""
+ amount = flt(amount)
+ remarks = (remarks or "").strip()
+ expense_account = _coerce_account_name(expense_account)
+
+ validate_pos_expense_enabled(pos_profile)
+ shift = validate_open_shift(pos_opening_shift, pos_profile)
+ validate_expense_amount(amount, pos_profile)
+ validate_expense_account(expense_account, shift.company)
+ validate_mode_of_payment(mode_of_payment, pos_profile, shift.company)
+ if employee:
+ validate_employee(employee, shift.company)
+
+ cost_center = frappe.db.get_value("POS Profile", pos_profile, "cost_center")
+ payment_account = _ensure_account_name(
+ _resolve_payment_account(mode_of_payment, shift.company),
+ _("Payment Account"),
+ )
+
+ journal_entry_name = _create_expense_journal_entry(
+ company=shift.company,
+ expense_account=expense_account,
+ payment_account=payment_account,
+ amount=amount,
+ cost_center=cost_center,
+ pos_opening_shift=pos_opening_shift,
+ pos_profile=pos_profile,
+ mode_of_payment=mode_of_payment,
+ employee=employee,
+ remarks=remarks,
+ )
+
+ return {
+ "name": journal_entry_name,
+ "journal_entry": journal_entry_name,
+ "amount": amount,
+ "message": _("POS Expense recorded in Journal Entry {0}").format(journal_entry_name),
+ }
+
+
+def validate_pos_expense_enabled(pos_profile):
+ if not pos_profile:
+ frappe.throw(_("POS Profile is required"))
+
+ if not cint(frappe.db.get_value("POS Profile", pos_profile, "posa_allow_pos_expense")):
+ frappe.throw(
+ _("POS Expense is not enabled for POS Profile {0}").format(frappe.bold(pos_profile)),
+ title=_("POS Expense Disabled"),
+ )
+
+
+def validate_open_shift(pos_opening_shift, pos_profile):
+ if not pos_opening_shift:
+ frappe.throw(_("POS Opening Shift is required"))
+
+ shift = frappe.db.get_value(
+ "POS Opening Shift",
+ pos_opening_shift,
+ ["name", "status", "pos_profile", "company", "user", "docstatus"],
+ as_dict=True,
+ )
+ if not shift or shift.docstatus != 1:
+ frappe.throw(_("POS Opening Shift {0} does not exist").format(pos_opening_shift))
+
+ if shift.status != "Open":
+ frappe.throw(_("POS Opening Shift must be open to record an expense"))
+
+ if shift.pos_profile != pos_profile:
+ frappe.throw(_("POS Opening Shift does not belong to the selected POS Profile"))
+
+ if shift.user != frappe.session.user:
+ frappe.throw(_("You can only record expenses for your own open shift"))
+
+ return shift
+
+
+def validate_expense_amount(amount, pos_profile):
+ if flt(amount) <= 0:
+ frappe.throw(_("Amount must be greater than zero"))
+
+ maximum_amount = flt(
+ frappe.db.get_value("POS Profile", pos_profile, "posa_maximum_expense_amount")
+ )
+ if maximum_amount > 0 and flt(amount) > maximum_amount:
+ frappe.throw(
+ _("Amount {0} exceeds the maximum allowed expense amount of {1}").format(
+ frappe.format_value(amount, {"fieldtype": "Currency"}),
+ frappe.format_value(maximum_amount, {"fieldtype": "Currency"}),
+ ),
+ title=_("Maximum Expense Amount Exceeded"),
+ )
+
+
+def validate_expense_account(expense_account, company):
+ if not expense_account:
+ frappe.throw(_("Expense Account is required"))
+
+ account = frappe.db.get_value(
+ "Account",
+ expense_account,
+ ["name", "company", "is_group", "disabled", "account_type", "root_type"],
+ as_dict=True,
+ )
+ if not account:
+ frappe.throw(_("Expense Account {0} does not exist").format(expense_account))
+
+ if account.company != company:
+ frappe.throw(_("Expense Account must belong to company {0}").format(company))
+
+ if account.is_group:
+ frappe.throw(_("Expense Account must be a ledger account"))
+
+ if account.disabled:
+ frappe.throw(_("Expense Account {0} is disabled").format(expense_account))
+
+ if account.account_type != "Expense" and account.root_type != "Expense":
+ frappe.throw(_("Selected account must be an expense account"))
+
+
+def validate_mode_of_payment(mode_of_payment, pos_profile, company):
+ if not mode_of_payment:
+ frappe.throw(_("Mode of Payment is required"))
+
+ profile_modes = frappe.get_all(
+ "POS Payment Method",
+ filters={"parent": pos_profile, "mode_of_payment": mode_of_payment},
+ pluck="name",
+ )
+ if not profile_modes:
+ frappe.throw(
+ _("Mode of Payment {0} is not configured in POS Profile {1}").format(
+ frappe.bold(mode_of_payment),
+ frappe.bold(pos_profile),
+ )
+ )
+
+ if not _resolve_payment_account(mode_of_payment, company):
+ frappe.throw(
+ _("Payment account is not configured for {0}").format(frappe.bold(mode_of_payment)),
+ title=_("Missing Payment Account"),
+ )
+
+
+def _coerce_account_name(account):
+ """Return an Account name from a string or payment-account lookup dict."""
+ while isinstance(account, dict):
+ account = account.get("account") or account.get("name") or account.get("value")
+
+ if account in (None, ""):
+ return None
+
+ return cstr(account).strip() or None
+
+
+def _ensure_account_name(account, label):
+ account_name = _coerce_account_name(account)
+ if not account_name:
+ frappe.throw(
+ _("{0} is required").format(label),
+ title=_("Missing Account"),
+ )
+ return account_name
+
+
+def _resolve_payment_account(mode_of_payment, company):
+ """Return the default cash/bank account name for a mode of payment."""
+ try:
+ from pos_next.api.invoices import get_payment_account
+
+ account_info = get_payment_account(mode_of_payment, company)
+ except Exception:
+ return None
+
+ return _coerce_account_name(account_info)
+
+
+def get_active_employees(company):
+ """Return active employees for the expense dialog (POS cashiers may lack Employee read perm)."""
+ filters = {"status": "Active"}
+ if company:
+ filters["company"] = company
+
+ return frappe.get_all(
+ "Employee",
+ filters=filters,
+ fields=["name", "employee_name"],
+ order_by="employee_name asc",
+ limit_page_length=200,
+ ignore_permissions=True,
+ )
+
+
+def validate_employee(employee, company):
+ if not frappe.db.exists("Employee", employee):
+ frappe.throw(_("Employee {0} does not exist").format(employee))
+
+ employee_company = frappe.db.get_value("Employee", employee, ["company", "status"], as_dict=True)
+ if not employee_company:
+ frappe.throw(_("Employee {0} does not exist").format(employee))
+
+ if employee_company.status != "Active":
+ frappe.throw(_("Employee {0} is not active").format(employee))
+
+ if company and employee_company.company and employee_company.company != company:
+ frappe.throw(_("Employee {0} does not belong to company {1}").format(employee, company))
+
+
+def get_expense_accounts(company):
+ return frappe.get_all(
+ "Account",
+ filters={
+ "company": company,
+ "is_group": 0,
+ "disabled": 0,
+ },
+ or_filters=[
+ ["account_type", "=", "Expense"],
+ ["root_type", "=", "Expense"],
+ ],
+ fields=["name", "account_name"],
+ order_by="name",
+ limit_page_length=0,
+ ignore_permissions=True,
+ )
+
+
+def _create_expense_journal_entry(
+ company,
+ expense_account,
+ payment_account,
+ amount,
+ cost_center,
+ pos_opening_shift,
+ pos_profile,
+ mode_of_payment,
+ employee,
+ remarks,
+):
+ user_remark = remarks or _("POS Expense for shift {0}").format(pos_opening_shift)
+ expense_account = _ensure_account_name(expense_account, _("Expense Account"))
+ payment_account = _ensure_account_name(payment_account, _("Payment Account"))
+
+ jv_doc = frappe.get_doc(
+ {
+ "doctype": "Journal Entry",
+ "voucher_type": "Journal Entry",
+ "posting_date": today(),
+ "company": company,
+ "user_remark": user_remark,
+ "cheque_no": pos_opening_shift,
+ "cheque_date": today(),
+ "posa_is_pos_expense": 1,
+ "posa_pos_opening_shift": pos_opening_shift,
+ "posa_pos_profile": pos_profile,
+ "posa_expense_account": expense_account,
+ "posa_expense_amount": amount,
+ "posa_expense_mode_of_payment": mode_of_payment,
+ "posa_expense_employee": employee,
+ }
+ )
+
+ expense_row = jv_doc.append("accounts", {})
+ expense_row.update(
+ {
+ "account": expense_account,
+ "debit_in_account_currency": amount,
+ "credit_in_account_currency": 0,
+ "cost_center": cost_center,
+ }
+ )
+
+ payment_row = jv_doc.append("accounts", {})
+ payment_row.update(
+ {
+ "account": payment_account,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": amount,
+ "cost_center": cost_center,
+ }
+ )
+
+ jv_doc.flags.ignore_permissions = True
+ jv_doc.insert()
+ jv_doc.submit()
+
+ return jv_doc.name
+
+
+def get_pos_expenses(pos_opening_shift):
+ """Return submitted POS expense Journal Entries for a shift."""
+ expenses = frappe.get_all(
+ "Journal Entry",
+ filters={
+ "posa_is_pos_expense": 1,
+ "posa_pos_opening_shift": pos_opening_shift,
+ "docstatus": 1,
+ },
+ fields=[
+ "name",
+ "posa_expense_account",
+ "posa_expense_amount",
+ "posa_expense_employee",
+ "posa_expense_mode_of_payment",
+ "user_remark",
+ ],
+ order_by="creation asc",
+ )
+
+ return [
+ frappe._dict(
+ name=expense.name,
+ journal_entry=expense.name,
+ expense_account=expense.posa_expense_account,
+ amount=flt(expense.posa_expense_amount),
+ employee=expense.posa_expense_employee or "",
+ remarks=(expense.user_remark or "").strip(),
+ mode_of_payment=expense.posa_expense_mode_of_payment,
+ )
+ for expense in expenses
+ ]
diff --git a/pos_next/api/test_expenses.py b/pos_next/api/test_expenses.py
new file mode 100644
index 000000000..10c1eb16a
--- /dev/null
+++ b/pos_next/api/test_expenses.py
@@ -0,0 +1,220 @@
+# Copyright (c) 2026, BrainWise and contributors
+# For license information, please see license.txt
+
+import unittest
+from types import SimpleNamespace
+from unittest.mock import patch
+
+from pos_next.api import expenses
+
+
+def _raise_runtime_error(message, *args, **kwargs):
+ raise RuntimeError(str(message))
+
+
+class TestPOSExpenses(unittest.TestCase):
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.db.get_value", return_value=0)
+ def test_validate_pos_expense_enabled_rejects_disabled_profile(
+ self, _mock_get_value, _mock_throw
+ ):
+ with self.assertRaisesRegex(RuntimeError, "POS Expense is not enabled"):
+ expenses.validate_pos_expense_enabled("Test POS Profile")
+
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ @patch("pos_next.api.expenses.frappe.session")
+ def test_validate_open_shift_rejects_closed_shift(
+ self, mock_session, mock_get_value, _mock_throw
+ ):
+ mock_session.user = "test@example.com"
+ mock_get_value.return_value = SimpleNamespace(
+ name="POS-OS-0001",
+ status="Closed",
+ pos_profile="Test POS Profile",
+ company="Test Company",
+ user="test@example.com",
+ docstatus=1,
+ )
+
+ with self.assertRaisesRegex(RuntimeError, "must be open"):
+ expenses.validate_open_shift("POS-OS-0001", "Test POS Profile")
+
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ @patch("pos_next.api.expenses.frappe.session")
+ def test_validate_open_shift_rejects_other_user(
+ self, mock_session, mock_get_value, _mock_throw
+ ):
+ mock_session.user = "other@example.com"
+ mock_get_value.return_value = SimpleNamespace(
+ name="POS-OS-0001",
+ status="Open",
+ pos_profile="Test POS Profile",
+ company="Test Company",
+ user="cashier@example.com",
+ docstatus=1,
+ )
+
+ with self.assertRaisesRegex(RuntimeError, "your own open shift"):
+ expenses.validate_open_shift("POS-OS-0001", "Test POS Profile")
+
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ def test_validate_expense_amount_rejects_zero(self, mock_get_value, _mock_throw):
+ mock_get_value.return_value = 0
+
+ with self.assertRaisesRegex(RuntimeError, "greater than zero"):
+ expenses.validate_expense_amount(0, "Test POS Profile")
+
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.format_value", side_effect=lambda value, _options: str(value))
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ def test_validate_expense_amount_rejects_over_maximum(
+ self, mock_get_value, _mock_format, _mock_throw
+ ):
+ mock_get_value.return_value = 100
+
+ with self.assertRaisesRegex(RuntimeError, "exceeds the maximum"):
+ expenses.validate_expense_amount(150, "Test POS Profile")
+
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ def test_validate_expense_account_rejects_non_expense(self, mock_get_value, _mock_throw):
+ mock_get_value.return_value = SimpleNamespace(
+ name="Cash - TC",
+ company="Test Company",
+ is_group=0,
+ disabled=0,
+ account_type="Cash",
+ root_type="Asset",
+ )
+
+ with self.assertRaisesRegex(RuntimeError, "must be an expense account"):
+ expenses.validate_expense_account("Cash - TC", "Test Company")
+
+ @patch("pos_next.api.expenses._resolve_payment_account", return_value=None)
+ @patch("pos_next.api.expenses.frappe.get_all", return_value=["row-1"])
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ def test_validate_mode_of_payment_requires_payment_account(
+ self, _mock_throw, _mock_get_all, _mock_resolve_payment_account
+ ):
+ with self.assertRaisesRegex(RuntimeError, "Payment account is not configured"):
+ expenses.validate_mode_of_payment("Cash", "Test POS Profile", "Test Company")
+
+ @patch("pos_next.api.invoices.get_payment_account", return_value={"account": "Cash - TC"})
+ def test_resolve_payment_account_extracts_account_name(self, _mock_get_payment_account):
+ self.assertEqual(expenses._resolve_payment_account("Cash", "Test Company"), "Cash - TC")
+
+ def test_coerce_account_name_handles_dict_and_string(self):
+ self.assertEqual(expenses._coerce_account_name({"account": "Cash - TC"}), "Cash - TC")
+ self.assertEqual(expenses._coerce_account_name("Cash - TC"), "Cash - TC")
+ self.assertEqual(
+ expenses._coerce_account_name({"account": {"account": "Cash - TC"}}),
+ "Cash - TC",
+ )
+
+ @patch("pos_next.api.expenses.frappe.get_all", return_value=[])
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ def test_validate_mode_of_payment_rejects_unconfigured_mode(self, _mock_throw, _mock_get_all):
+ with self.assertRaisesRegex(RuntimeError, "is not configured in POS Profile"):
+ expenses.validate_mode_of_payment("Cash", "Test POS Profile", "Test Company")
+
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ @patch("pos_next.api.expenses.frappe.db.exists", return_value=False)
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ def test_validate_employee_rejects_missing_employee(
+ self, _mock_throw, _mock_exists, _mock_get_value
+ ):
+ with self.assertRaisesRegex(RuntimeError, "does not exist"):
+ expenses.validate_employee("EMP-0001", "Test Company")
+
+ @patch("pos_next.api.expenses.frappe.get_all")
+ def test_get_active_employees_uses_ignore_permissions(self, mock_get_all):
+ mock_get_all.return_value = [{"name": "EMP-0001", "employee_name": "John Doe"}]
+
+ result = expenses.get_active_employees("Test Company")
+
+ self.assertEqual(result[0]["name"], "EMP-0001")
+ mock_get_all.assert_called_once_with(
+ "Employee",
+ filters={"status": "Active", "company": "Test Company"},
+ fields=["name", "employee_name"],
+ order_by="employee_name asc",
+ limit_page_length=200,
+ ignore_permissions=True,
+ )
+
+ @patch("pos_next.api.expenses._create_expense_journal_entry", return_value="ACC-JV-0001")
+ @patch("pos_next.api.expenses.validate_employee")
+ @patch("pos_next.api.expenses.validate_mode_of_payment")
+ @patch("pos_next.api.expenses.validate_expense_account")
+ @patch("pos_next.api.expenses.validate_expense_amount")
+ @patch("pos_next.api.expenses.validate_open_shift")
+ @patch("pos_next.api.expenses.validate_pos_expense_enabled")
+ @patch("pos_next.api.expenses._resolve_payment_account", return_value="Cash - TC")
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ def test_create_pos_expense_creates_journal_entry_only(
+ self,
+ mock_db_get_value,
+ _mock_resolve_payment_account,
+ _mock_validate_enabled,
+ mock_validate_shift,
+ _mock_validate_amount,
+ _mock_validate_account,
+ _mock_validate_mode,
+ _mock_validate_employee,
+ mock_create_je,
+ ):
+ mock_validate_shift.return_value = SimpleNamespace(company="Test Company")
+ mock_db_get_value.return_value = "Main - TC"
+
+ result = expenses.create_pos_expense(
+ "POS-OS-0001",
+ "Test POS Profile",
+ "Travel Expenses - TC",
+ 50,
+ "Cash",
+ employee="EMP-0001",
+ remarks="Fuel",
+ )
+
+ self.assertEqual(result["name"], "ACC-JV-0001")
+ self.assertEqual(result["journal_entry"], "ACC-JV-0001")
+ mock_create_je.assert_called_once()
+
+ @patch("pos_next.api.expenses.frappe.get_all")
+ def test_get_pos_expenses_reads_journal_entries(self, mock_get_all):
+ mock_get_all.return_value = [
+ SimpleNamespace(
+ name="ACC-JV-0001",
+ posa_expense_account="Travel Expenses - TC",
+ posa_expense_amount=50,
+ posa_expense_employee="EMP-0001",
+ posa_expense_mode_of_payment="Cash",
+ user_remark="Fuel",
+ )
+ ]
+
+ result = expenses.get_pos_expenses("POS-OS-0001")
+
+ self.assertEqual(result[0].journal_entry, "ACC-JV-0001")
+ self.assertEqual(result[0].amount, 50)
+ self.assertEqual(result[0].mode_of_payment, "Cash")
+ mock_get_all.assert_called_once_with(
+ "Journal Entry",
+ filters={
+ "posa_is_pos_expense": 1,
+ "posa_pos_opening_shift": "POS-OS-0001",
+ "docstatus": 1,
+ },
+ fields=[
+ "name",
+ "posa_expense_account",
+ "posa_expense_amount",
+ "posa_expense_employee",
+ "posa_expense_mode_of_payment",
+ "user_remark",
+ ],
+ order_by="creation asc",
+ )
diff --git a/pos_next/pos_next/custom/journal_entry.json b/pos_next/pos_next/custom/journal_entry.json
new file mode 100644
index 000000000..a9d9e4565
--- /dev/null
+++ b/pos_next/pos_next/custom/journal_entry.json
@@ -0,0 +1,95 @@
+{
+ "custom_fields": [
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_pos_expense_section",
+ "fieldtype": "Section Break",
+ "insert_after": "user_remark",
+ "label": "POS Expense",
+ "depends_on": "eval:doc.posa_is_pos_expense",
+ "module": "POS Next",
+ "name": "Journal Entry-posa_pos_expense_section"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_is_pos_expense",
+ "fieldtype": "Check",
+ "insert_after": "posa_pos_expense_section",
+ "label": "Is POS Expense",
+ "read_only": 1,
+ "default": "0",
+ "module": "POS Next",
+ "name": "Journal Entry-posa_is_pos_expense"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_pos_opening_shift",
+ "fieldtype": "Link",
+ "insert_after": "posa_is_pos_expense",
+ "label": "POS Opening Shift",
+ "options": "POS Opening Shift",
+ "read_only": 1,
+ "module": "POS Next",
+ "name": "Journal Entry-posa_pos_opening_shift"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_pos_profile",
+ "fieldtype": "Link",
+ "insert_after": "posa_pos_opening_shift",
+ "label": "POS Profile",
+ "options": "POS Profile",
+ "read_only": 1,
+ "module": "POS Next",
+ "name": "Journal Entry-posa_pos_profile"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_expense_account",
+ "fieldtype": "Link",
+ "insert_after": "posa_pos_profile",
+ "label": "Expense Account",
+ "options": "Account",
+ "read_only": 1,
+ "module": "POS Next",
+ "name": "Journal Entry-posa_expense_account"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_expense_amount",
+ "fieldtype": "Currency",
+ "insert_after": "posa_expense_account",
+ "label": "Expense Amount",
+ "read_only": 1,
+ "module": "POS Next",
+ "name": "Journal Entry-posa_expense_amount"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_expense_mode_of_payment",
+ "fieldtype": "Link",
+ "insert_after": "posa_expense_amount",
+ "label": "Mode of Payment",
+ "options": "Mode of Payment",
+ "read_only": 1,
+ "module": "POS Next",
+ "name": "Journal Entry-posa_expense_mode_of_payment"
+ },
+ {
+ "dt": "Journal Entry",
+ "fieldname": "posa_expense_employee",
+ "fieldtype": "Link",
+ "insert_after": "posa_expense_mode_of_payment",
+ "label": "Employee",
+ "options": "Employee",
+ "read_only": 1,
+ "module": "POS Next",
+ "name": "Journal Entry-posa_expense_employee"
+ }
+ ],
+ "custom_perms": [],
+ "doctype": "Journal Entry",
+ "links": [],
+ "property_setters": [],
+ "sync_on_migrate": 1
+}
diff --git a/pos_next/pos_next/custom/pos_profile.json b/pos_next/pos_next/custom/pos_profile.json
index 2306f351d..15fb9d440 100644
--- a/pos_next/pos_next/custom/pos_profile.json
+++ b/pos_next/pos_next/custom/pos_profile.json
@@ -255,6 +255,134 @@
"translatable": 0,
"unique": 0,
"width": null
+ },
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "creation": "2026-06-14 10:00:00.000000",
+ "default": "0",
+ "depends_on": null,
+ "description": "Enable expense recording from POS",
+ "docstatus": 0,
+ "dt": "POS Profile",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "posa_allow_pos_expense",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 57,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "posa_allow_delete",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Allow POS Expense",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-06-14 10:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "POS Next",
+ "name": "POS Profile-posa_allow_pos_expense",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "owner": "Administrator",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": "eval:doc.posa_allow_pos_expense",
+ "columns": 0,
+ "creation": "2026-06-14 10:00:00.000000",
+ "default": null,
+ "depends_on": "eval:doc.posa_allow_pos_expense",
+ "description": "Maximum amount a cashier can spend in a single POS Expense transaction",
+ "docstatus": 0,
+ "dt": "POS Profile",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "posa_maximum_expense_amount",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 58,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "posa_allow_pos_expense",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Maximum Expense Amount",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-06-14 10:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "POS Next",
+ "name": "POS Profile-posa_maximum_expense_amount",
+ "no_copy": 0,
+ "non_negative": 1,
+ "options": null,
+ "owner": "Administrator",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
}
],
"custom_perms": [],
diff --git a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.json b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.json
index 5cac9b946..fd9aa5e47 100644
--- a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.json
+++ b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.json
@@ -21,6 +21,9 @@
"pos_transactions",
"section_break_9",
"pos_payments",
+ "section_break_pos_expenses",
+ "pos_expenses",
+ "total_pos_expenses",
"section_break_if3m1",
"payment_reconciliation_details",
"section_break_11",
@@ -193,6 +196,25 @@
"label": "POS Payments",
"options": "POS Payment Entry Reference"
},
+ {
+ "fieldname": "section_break_pos_expenses",
+ "fieldtype": "Section Break",
+ "label": "POS Expenses"
+ },
+ {
+ "fieldname": "pos_expenses",
+ "fieldtype": "Table",
+ "label": "POS Expenses",
+ "options": "POS Closing Shift Expense",
+ "read_only": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "total_pos_expenses",
+ "fieldtype": "Currency",
+ "label": "Total POS Expenses",
+ "read_only": 1
+ },
{
"fieldname": "section_break_if3m1",
"fieldtype": "Section Break"
diff --git a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py
index f45c56d10..0f733977d 100644
--- a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py
+++ b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py
@@ -583,10 +583,37 @@ def make_closing_shift_from_opening(opening_shift):
amount = get_base_value(py, "paid_amount", "base_paid_amount")
_aggregate_payment(payments, py.mode_of_payment, amount)
+ # Process POS Expenses — reduce expected cash per payment mode
+ from pos_next.api.expenses import get_pos_expenses
+
+ pos_expenses_table = []
+ expenses_total = 0
+ expense_by_mode = defaultdict(float)
+
+ for expense in get_pos_expenses(opening_shift.get("name")):
+ expense_amount = flt(expense.amount)
+ expenses_total += expense_amount
+ expense_by_mode[expense.mode_of_payment] += expense_amount
+ pos_expenses_table.append(
+ frappe._dict(
+ {
+ "expense_account": expense.expense_account,
+ "amount": expense_amount,
+ "employee": expense.employee or "",
+ "remarks": expense.remarks or "",
+ }
+ )
+ )
+ _aggregate_payment(payments, expense.mode_of_payment, -expense_amount)
+
+ for pay in payments:
+ pay.expense_amount = expense_by_mode.get(pay.mode_of_payment, 0)
+
# Update closing shift with totals
closing_shift.grand_total = summary["grand_total"]
closing_shift.net_total = summary["net_total"]
closing_shift.total_quantity = summary["total_quantity"]
+ closing_shift.total_pos_expenses = expenses_total
# Set child tables (without return info - that's for display only)
closing_shift.set("pos_transactions", [
@@ -596,6 +623,7 @@ def make_closing_shift_from_opening(opening_shift):
closing_shift.set("payment_reconciliation", payments)
closing_shift.set("taxes", taxes)
closing_shift.set("pos_payments", pos_payments_table)
+ closing_shift.set("pos_expenses", pos_expenses_table)
# Build response with display-only fields
result = closing_shift.as_dict()
@@ -604,6 +632,9 @@ def make_closing_shift_from_opening(opening_shift):
"returns_count": summary["returns_count"],
"sales_total": summary["sales_total"],
"sales_count": summary["sales_count"],
+ "expenses_total": expenses_total,
+ "expenses_count": len(pos_expenses_table),
+ "pos_expenses": pos_expenses_table,
"pos_transactions": pos_transactions, # Include return info for display
})
diff --git a/pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.json b/pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.json
new file mode 100644
index 000000000..94c837d88
--- /dev/null
+++ b/pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.json
@@ -0,0 +1,55 @@
+{
+ "actions": [],
+ "creation": "2026-06-14 10:00:00.000000",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "expense_account",
+ "amount",
+ "employee",
+ "remarks"
+ ],
+ "fields": [
+ {
+ "fieldname": "expense_account",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Expense Account",
+ "read_only": 1
+ },
+ {
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Amount",
+ "read_only": 1
+ },
+ {
+ "fieldname": "employee",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Employee",
+ "read_only": 1
+ },
+ {
+ "fieldname": "remarks",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Remarks",
+ "read_only": 1
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2026-06-14 10:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "POS Next",
+ "name": "POS Closing Shift Expense",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
+}
diff --git a/pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.py b/pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.py
new file mode 100644
index 000000000..bef3a7274
--- /dev/null
+++ b/pos_next/pos_next/doctype/pos_closing_shift_expense/pos_closing_shift_expense.py
@@ -0,0 +1,8 @@
+# Copyright (c) 2026, BrainWise and contributors
+# For license information, please see license.txt
+
+from frappe.model.document import Document
+
+
+class POSClosingShiftExpense(Document):
+ pass
diff --git a/pos_next/pos_next/report/pos_expense_report/pos_expense_report.js b/pos_next/pos_next/report/pos_expense_report/pos_expense_report.js
new file mode 100644
index 000000000..51a536833
--- /dev/null
+++ b/pos_next/pos_next/report/pos_expense_report/pos_expense_report.js
@@ -0,0 +1,50 @@
+// Copyright (c) 2026, BrainWise and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["POS Expense Report"] = {
+ filters: [
+ {
+ fieldname: "from_date",
+ label: __("From Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.add_days(frappe.datetime.get_today(), -30),
+ },
+ {
+ fieldname: "to_date",
+ label: __("To Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.get_today(),
+ },
+ {
+ fieldname: "company",
+ label: __("Company"),
+ fieldtype: "Link",
+ options: "Company",
+ default: frappe.defaults.get_user_default("Company"),
+ },
+ {
+ fieldname: "pos_profile",
+ label: __("POS Profile"),
+ fieldtype: "Link",
+ options: "POS Profile",
+ },
+ {
+ fieldname: "pos_opening_shift",
+ label: __("POS Opening Shift"),
+ fieldtype: "Link",
+ options: "POS Opening Shift",
+ },
+ {
+ fieldname: "mode_of_payment",
+ label: __("Mode of Payment"),
+ fieldtype: "Link",
+ options: "Mode of Payment",
+ },
+ {
+ fieldname: "employee",
+ label: __("Employee"),
+ fieldtype: "Link",
+ options: "Employee",
+ },
+ ],
+};
diff --git a/pos_next/pos_next/report/pos_expense_report/pos_expense_report.json b/pos_next/pos_next/report/pos_expense_report/pos_expense_report.json
new file mode 100644
index 000000000..261aa34fc
--- /dev/null
+++ b/pos_next/pos_next/report/pos_expense_report/pos_expense_report.json
@@ -0,0 +1,40 @@
+{
+ "add_total_row": 1,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2026-06-14 18:00:00.000000",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": null,
+ "modified": "2026-06-14 18:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "POS Next",
+ "name": "POS Expense Report",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Journal Entry",
+ "report_name": "POS Expense Report",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Accounts Manager"
+ },
+ {
+ "role": "Sales Manager"
+ },
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Nexus POS Manager"
+ },
+ {
+ "role": "POSNext Cashier"
+ }
+ ],
+ "timeout": 0
+}
diff --git a/pos_next/pos_next/report/pos_expense_report/pos_expense_report.py b/pos_next/pos_next/report/pos_expense_report/pos_expense_report.py
new file mode 100644
index 000000000..5ff0b86ea
--- /dev/null
+++ b/pos_next/pos_next/report/pos_expense_report/pos_expense_report.py
@@ -0,0 +1,152 @@
+# Copyright (c) 2026, BrainWise and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+
+def execute(filters=None):
+ columns = get_columns()
+ data = get_data(filters)
+ return columns, data
+
+
+def get_columns():
+ return [
+ {
+ "fieldname": "journal_entry",
+ "label": _("Journal Entry"),
+ "fieldtype": "Link",
+ "options": "Journal Entry",
+ "width": 150,
+ },
+ {
+ "fieldname": "posting_date",
+ "label": _("Posting Date"),
+ "fieldtype": "Date",
+ "width": 110,
+ },
+ {
+ "fieldname": "company",
+ "label": _("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "width": 140,
+ },
+ {
+ "fieldname": "pos_opening_shift",
+ "label": _("POS Opening Shift"),
+ "fieldtype": "Link",
+ "options": "POS Opening Shift",
+ "width": 160,
+ },
+ {
+ "fieldname": "pos_profile",
+ "label": _("POS Profile"),
+ "fieldtype": "Link",
+ "options": "POS Profile",
+ "width": 140,
+ },
+ {
+ "fieldname": "expense_account",
+ "label": _("Expense Account"),
+ "fieldtype": "Link",
+ "options": "Account",
+ "width": 180,
+ },
+ {
+ "fieldname": "amount",
+ "label": _("Amount"),
+ "fieldtype": "Currency",
+ "width": 120,
+ },
+ {
+ "fieldname": "mode_of_payment",
+ "label": _("Mode of Payment"),
+ "fieldtype": "Link",
+ "options": "Mode of Payment",
+ "width": 140,
+ },
+ {
+ "fieldname": "employee",
+ "label": _("Employee"),
+ "fieldtype": "Link",
+ "options": "Employee",
+ "width": 140,
+ },
+ {
+ "fieldname": "remarks",
+ "label": _("Remarks"),
+ "fieldtype": "Data",
+ "width": 220,
+ },
+ {
+ "fieldname": "created_by",
+ "label": _("Created By"),
+ "fieldtype": "Link",
+ "options": "User",
+ "width": 140,
+ },
+ ]
+
+
+def get_data(filters):
+ filters = filters or {}
+ conditions = ["je.posa_is_pos_expense = 1", "je.docstatus = 1"]
+ values = {}
+
+ if filters.get("company"):
+ conditions.append("je.company = %(company)s")
+ values["company"] = filters["company"]
+
+ if filters.get("from_date"):
+ conditions.append("je.posting_date >= %(from_date)s")
+ values["from_date"] = filters["from_date"]
+
+ if filters.get("to_date"):
+ conditions.append("je.posting_date <= %(to_date)s")
+ values["to_date"] = filters["to_date"]
+
+ if filters.get("pos_profile"):
+ conditions.append("je.posa_pos_profile = %(pos_profile)s")
+ values["pos_profile"] = filters["pos_profile"]
+
+ if filters.get("pos_opening_shift"):
+ conditions.append("je.posa_pos_opening_shift = %(pos_opening_shift)s")
+ values["pos_opening_shift"] = filters["pos_opening_shift"]
+
+ if filters.get("mode_of_payment"):
+ conditions.append("je.posa_expense_mode_of_payment = %(mode_of_payment)s")
+ values["mode_of_payment"] = filters["mode_of_payment"]
+
+ if filters.get("employee"):
+ conditions.append("je.posa_expense_employee = %(employee)s")
+ values["employee"] = filters["employee"]
+
+ rows = frappe.db.sql(
+ f"""
+ SELECT
+ je.name AS journal_entry,
+ je.posting_date,
+ je.company,
+ je.posa_pos_opening_shift AS pos_opening_shift,
+ je.posa_pos_profile AS pos_profile,
+ je.posa_expense_account AS expense_account,
+ je.posa_expense_amount AS amount,
+ je.posa_expense_mode_of_payment AS mode_of_payment,
+ je.posa_expense_employee AS employee,
+ je.user_remark AS remarks,
+ je.owner AS created_by
+ FROM `tabJournal Entry` je
+ WHERE {" AND ".join(conditions)}
+ ORDER BY je.posting_date DESC, je.creation DESC
+ """,
+ values,
+ as_dict=True,
+ )
+
+ for row in rows:
+ row.amount = flt(row.amount)
+
+ return rows
diff --git a/pos_next/pos_next/workspace/posnext/posnext.json b/pos_next/pos_next/workspace/posnext/posnext.json
index 7563279cb..cda2b49fe 100644
--- a/pos_next/pos_next/workspace/posnext/posnext.json
+++ b/pos_next/pos_next/workspace/posnext/posnext.json
@@ -16,7 +16,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Reports",
- "link_count": 5,
+ "link_count": 6,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
@@ -51,6 +51,16 @@
"onboard": 0,
"type": "Link"
},
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "POS Expense Report",
+ "link_count": 0,
+ "link_to": "POS Expense Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"hidden": 0,
"is_query_report": 1,
diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv
index f89e6390a..5332da36f 100644
--- a/pos_next/translations/ar.csv
+++ b/pos_next/translations/ar.csv
@@ -1557,6 +1557,45 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط:
"Sync in progress — deletion disabled","المزامنة جارية — الحذف غير متاح حاليًا",""
"OFFLINE — PENDING SYNC","غير متصل — بانتظار المزامنة",""
"This offline receipt is no longer in browser storage. Sync the invoice, then print from history.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم اطبعها من السجل.",""
+"POS Expense","صرف مصروف",""
+"Record POS expense","تسجيل مصروف من نقطة البيع",""
+"Loading expense data...","جاري تحميل بيانات المصروف...",""
+"Select expense account","اختر حساب المصروف",""
+"Search accounts...","بحث في الحسابات...",""
+"No accounts found","لم يتم العثور على حسابات",""
+"Enter amount","أدخل المبلغ",""
+"Maximum allowed: {0}","الحد الأقصى المسموح: {0}",""
+"Select payment method","اختر طريقة الدفع",""
+"Select employee (optional)","اختر الموظف (اختياري)",""
+"Search employees...","بحث في الموظفين...",""
+"No employees found","لم يتم العثور على موظفين",""
+"Optional remarks","ملاحظات اختيارية",""
+"An active POS shift is required","يلزم وجود وردية نقطة بيع نشطة",""
+"Unable to load expense data","تعذر تحميل بيانات المصروف",""
+"POS Expense created successfully","تم إنشاء مصروف نقطة البيع بنجاح",""
+"POS Expense recorded successfully","تم تسجيل مصروف نقطة البيع بنجاح",""
+"POS Expense recorded in Journal Entry {0}","تم تسجيل مصروف نقطة البيع في قيد اليومية {0}",""
+"POS Expense Report","تقرير مصروفات نقطة البيع",""
+"Failed to create POS Expense","فشل إنشاء مصروف نقطة البيع",""
+"Expense Account is required","حساب المصروف مطلوب",""
+"Amount must be greater than zero","يجب أن يكون المبلغ أكبر من صفر",""
+"Amount exceeds the maximum allowed expense amount of {0}","المبلغ يتجاوز الحد الأقصى المسموح للمصروف {0}",""
+"Mode of Payment is required","طريقة الدفع مطلوبة",""
+"POS Expenses","مصروفات نقطة البيع",""
+"{0} expenses","{0} مصروفات",""
+"Net Cash Impact","صافي التأثير النقدي",""
+"Sales - Returns - Expenses","المبيعات - المرتجعات - المصروفات",""
+"{0} expenses totaling {1}","{0} مصروفات بإجمالي {1}",""
+"Expense Account","حساب المصروف",""
+"Employee","الموظف",""
+"Remarks","ملاحظات",""
+"Expenses: -{0}","المصروفات: -{0}",""
+"POS expenses cannot be recorded while offline. Please connect to the internet and try again.","لا يمكن تسجيل مصروفات نقطة البيع دون اتصال. يُرجى الاتصال بالإنترنت والمحاولة مرة أخرى.",""
+"Unable to connect to server. Check your internet connection.","تعذر الاتصال بالخادم. تحقق من اتصال الإنترنت.",""
+"Search expense account...","ابحث عن حساب المصروف...",""
+"Search payment method...","ابحث عن طريقة الدفع...",""
+"Search employee...","ابحث عن الموظف...",""
+"No expense accounts found for this company.","لم يتم العثور على حسابات مصروفات لهذه الشركة.",""
"Invoice {0} saved offline and sent to printer — will sync when online","تم حفظ الفاتورة {0} دون اتصال وإرسالها إلى الطابعة — وستتم مزامنتها عند عودة الاتصال",""
"Invoice {0} saved offline but print failed — open Print from the success dialog","تم حفظ الفاتورة {0} دون اتصال، لكن تعذّرت الطباعة — افتح خيار الطباعة من نافذة النجاح",""
"Popup blocked — check your browser settings.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.",""
\ No newline at end of file
From abb96b89264f4de573901881f4edec383ccfaab3 Mon Sep 17 00:00:00 2001
From: MohamedAliSmk
Date: Tue, 16 Jun 2026 15:34:33 +0300
Subject: [PATCH 2/3] feat(expenses): enhance shift expense management and
validation
- Introduced computed properties for shift expense totals and remaining allowances in ExpenseDialog.
- Updated validation logic to check against remaining shift expense allowance instead of maximum expense amount.
- Modified API to return shift expense totals and remaining amounts for better expense tracking.
- Enhanced translations to reflect new shift expense limit messages and summaries.
---
POS/src/components/sale/ExpenseDialog.vue | 39 ++++++++++++++---
pos_next/api/expenses.py | 52 ++++++++++++++++++++---
pos_next/api/test_expenses.py | 34 +++++++++++++--
pos_next/pos_next/custom/pos_profile.json | 2 +-
pos_next/translations/ar.csv | 6 +++
pos_next/translations/id.csv | 6 +++
pos_next/translations/pt-br.csv | 6 +++
7 files changed, 129 insertions(+), 16 deletions(-)
diff --git a/POS/src/components/sale/ExpenseDialog.vue b/POS/src/components/sale/ExpenseDialog.vue
index 1e81589a5..f73842692 100644
--- a/POS/src/components/sale/ExpenseDialog.vue
+++ b/POS/src/components/sale/ExpenseDialog.vue
@@ -56,7 +56,7 @@
v-if="maximumExpenseAmount > 0"
class="mt-1 text-xs text-gray-500 text-start"
>
- {{ __("Maximum allowed: {0}", [formatCurrency(maximumExpenseAmount)]) }}
+ {{ shiftExpenseLimitSummary }}
@@ -179,6 +179,35 @@ const maximumExpenseAmount = computed(
0,
)
+const shiftExpenseTotal = computed(
+ () => Number.parseFloat(dialogDataResource.data?.shift_expense_total) || 0,
+)
+
+const remainingExpenseAmount = computed(() => {
+ if (maximumExpenseAmount.value <= 0) {
+ return 0
+ }
+
+ const remaining = Number.parseFloat(dialogDataResource.data?.remaining_expense_amount)
+ if (Number.isFinite(remaining)) {
+ return Math.max(0, remaining)
+ }
+
+ return Math.max(0, maximumExpenseAmount.value - shiftExpenseTotal.value)
+})
+
+const shiftExpenseLimitSummary = computed(() => {
+ if (maximumExpenseAmount.value <= 0) {
+ return ""
+ }
+
+ return [
+ __("Shift limit: {0}", { 0: formatCurrency(maximumExpenseAmount.value) }),
+ __("Recorded: {0}", { 0: formatCurrency(shiftExpenseTotal.value) }),
+ __("Remaining: {0}", { 0: formatCurrency(remainingExpenseAmount.value) }),
+ ].join(" | ")
+})
+
const dialogDataResource = createResource({
url: "pos_next.api.expenses.get_expense_dialog_data",
makeParams() {
@@ -288,10 +317,10 @@ function validateForm() {
return __("Amount must be greater than zero")
}
- if (maximumExpenseAmount.value > 0 && amount > maximumExpenseAmount.value) {
- return __("Amount exceeds the maximum allowed expense amount of {0}", [
- formatCurrency(maximumExpenseAmount.value),
- ])
+ if (maximumExpenseAmount.value > 0 && amount > remainingExpenseAmount.value) {
+ return __("Amount exceeds the remaining shift expense allowance of {0}", {
+ 0: formatCurrency(remainingExpenseAmount.value),
+ })
}
if (!form.mode_of_payment) {
diff --git a/pos_next/api/expenses.py b/pos_next/api/expenses.py
index b4ccb6e96..a6cb823e5 100644
--- a/pos_next/api/expenses.py
+++ b/pos_next/api/expenses.py
@@ -21,6 +21,10 @@ def get_expense_dialog_data(pos_profile, pos_opening_shift):
maximum_expense_amount = flt(
frappe.db.get_value("POS Profile", pos_profile, "posa_maximum_expense_amount")
)
+ shift_expense_total = get_shift_expense_total(pos_opening_shift)
+ remaining_expense_amount = _get_remaining_shift_expense_amount(
+ maximum_expense_amount, shift_expense_total
+ )
from pos_next.api.pos_profile import get_payment_methods
@@ -29,6 +33,8 @@ def get_expense_dialog_data(pos_profile, pos_opening_shift):
"payment_methods": get_payment_methods(pos_profile),
"employees": get_active_employees(company),
"maximum_expense_amount": maximum_expense_amount,
+ "shift_expense_total": shift_expense_total,
+ "remaining_expense_amount": remaining_expense_amount,
}
@@ -49,7 +55,7 @@ def create_pos_expense(
validate_pos_expense_enabled(pos_profile)
shift = validate_open_shift(pos_opening_shift, pos_profile)
- validate_expense_amount(amount, pos_profile)
+ validate_expense_amount(amount, pos_profile, pos_opening_shift)
validate_expense_account(expense_account, shift.company)
validate_mode_of_payment(mode_of_payment, pos_profile, shift.company)
if employee:
@@ -118,23 +124,57 @@ def validate_open_shift(pos_opening_shift, pos_profile):
return shift
-def validate_expense_amount(amount, pos_profile):
+def validate_expense_amount(amount, pos_profile, pos_opening_shift=None):
if flt(amount) <= 0:
frappe.throw(_("Amount must be greater than zero"))
maximum_amount = flt(
frappe.db.get_value("POS Profile", pos_profile, "posa_maximum_expense_amount")
)
- if maximum_amount > 0 and flt(amount) > maximum_amount:
+ if maximum_amount <= 0:
+ return
+
+ shift_total = get_shift_expense_total(pos_opening_shift) if pos_opening_shift else 0
+ new_shift_total = shift_total + flt(amount)
+ if new_shift_total > maximum_amount:
+ remaining = _get_remaining_shift_expense_amount(maximum_amount, shift_total)
frappe.throw(
- _("Amount {0} exceeds the maximum allowed expense amount of {1}").format(
- frappe.format_value(amount, {"fieldtype": "Currency"}),
+ _(
+ "This expense would exceed the shift expense limit of {0}. "
+ "Expenses recorded this shift: {1}. Remaining allowance: {2}"
+ ).format(
frappe.format_value(maximum_amount, {"fieldtype": "Currency"}),
+ frappe.format_value(shift_total, {"fieldtype": "Currency"}),
+ frappe.format_value(remaining, {"fieldtype": "Currency"}),
),
- title=_("Maximum Expense Amount Exceeded"),
+ title=_("Shift Expense Limit Exceeded"),
)
+def get_shift_expense_total(pos_opening_shift):
+ """Return the total submitted POS expense amount for an opening shift."""
+ if not pos_opening_shift:
+ return 0
+
+ total = frappe.db.sql(
+ """
+ SELECT COALESCE(SUM(posa_expense_amount), 0)
+ FROM `tabJournal Entry`
+ WHERE posa_is_pos_expense = 1
+ AND posa_pos_opening_shift = %s
+ AND docstatus = 1
+ """,
+ pos_opening_shift,
+ )
+ return flt(total[0][0] if total else 0)
+
+
+def _get_remaining_shift_expense_amount(maximum_amount, shift_expense_total):
+ if flt(maximum_amount) <= 0:
+ return 0
+ return max(0, flt(maximum_amount) - flt(shift_expense_total))
+
+
def validate_expense_account(expense_account, company):
if not expense_account:
frappe.throw(_("Expense Account is required"))
diff --git a/pos_next/api/test_expenses.py b/pos_next/api/test_expenses.py
index 10c1eb16a..4124c86aa 100644
--- a/pos_next/api/test_expenses.py
+++ b/pos_next/api/test_expenses.py
@@ -69,14 +69,40 @@ def test_validate_expense_amount_rejects_zero(self, mock_get_value, _mock_throw)
@patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
@patch("pos_next.api.expenses.frappe.format_value", side_effect=lambda value, _options: str(value))
+ @patch("pos_next.api.expenses.get_shift_expense_total", return_value=0)
@patch("pos_next.api.expenses.frappe.db.get_value")
- def test_validate_expense_amount_rejects_over_maximum(
- self, mock_get_value, _mock_format, _mock_throw
+ def test_validate_expense_amount_rejects_over_shift_limit(
+ self, mock_get_value, _mock_shift_total, _mock_format, _mock_throw
):
mock_get_value.return_value = 100
- with self.assertRaisesRegex(RuntimeError, "exceeds the maximum"):
- expenses.validate_expense_amount(150, "Test POS Profile")
+ with self.assertRaisesRegex(RuntimeError, "shift expense limit"):
+ expenses.validate_expense_amount(150, "Test POS Profile", "POS-OS-0001")
+
+ @patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
+ @patch("pos_next.api.expenses.frappe.format_value", side_effect=lambda value, _options: str(value))
+ @patch("pos_next.api.expenses.get_shift_expense_total", return_value=80)
+ @patch("pos_next.api.expenses.frappe.db.get_value")
+ def test_validate_expense_amount_rejects_when_cumulative_exceeds_limit(
+ self, mock_get_value, _mock_shift_total, _mock_format, _mock_throw
+ ):
+ mock_get_value.return_value = 100
+
+ with self.assertRaisesRegex(RuntimeError, "shift expense limit"):
+ expenses.validate_expense_amount(30, "Test POS Profile", "POS-OS-0001")
+
+ @patch("pos_next.api.expenses.frappe.db.sql", return_value=((80,),))
+ def test_get_shift_expense_total_sums_submitted_journal_entries(self, mock_sql):
+ total = expenses.get_shift_expense_total("POS-OS-0001")
+
+ self.assertEqual(total, 80)
+ mock_sql.assert_called_once()
+
+ @patch("pos_next.api.expenses.get_shift_expense_total", return_value=0)
+ def test_get_remaining_shift_expense_amount(self, _mock_shift_total):
+ self.assertEqual(expenses._get_remaining_shift_expense_amount(100, 30), 70)
+ self.assertEqual(expenses._get_remaining_shift_expense_amount(100, 120), 0)
+ self.assertEqual(expenses._get_remaining_shift_expense_amount(0, 50), 0)
@patch("pos_next.api.expenses.frappe.throw", side_effect=_raise_runtime_error)
@patch("pos_next.api.expenses.frappe.db.get_value")
diff --git a/pos_next/pos_next/custom/pos_profile.json b/pos_next/pos_next/custom/pos_profile.json
index 15fb9d440..9fae3042f 100644
--- a/pos_next/pos_next/custom/pos_profile.json
+++ b/pos_next/pos_next/custom/pos_profile.json
@@ -334,7 +334,7 @@
"creation": "2026-06-14 10:00:00.000000",
"default": null,
"depends_on": "eval:doc.posa_allow_pos_expense",
- "description": "Maximum amount a cashier can spend in a single POS Expense transaction",
+ "description": "Maximum total POS Expense amount allowed per shift session",
"docstatus": 0,
"dt": "POS Profile",
"fetch_from": null,
diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv
index 415232969..571043508 100644
--- a/pos_next/translations/ar.csv
+++ b/pos_next/translations/ar.csv
@@ -1580,6 +1580,12 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط:
"Expense Account is required","حساب المصروف مطلوب",""
"Amount must be greater than zero","يجب أن يكون المبلغ أكبر من صفر",""
"Amount exceeds the maximum allowed expense amount of {0}","المبلغ يتجاوز الحد الأقصى المسموح للمصروف {0}",""
+"Amount exceeds the remaining shift expense allowance of {0}","المبلغ يتجاوز المسموح المتبقي لمصروفات الوردية وهو {0}",""
+"Shift limit: {0}","حد الوردية: {0}",""
+"Recorded: {0}","المسجل: {0}",""
+"Remaining: {0}","المتبقي: {0}",""
+"This expense would exceed the shift expense limit of {0}. Expenses recorded this shift: {1}. Remaining allowance: {2}","سيؤدي هذا المصروف إلى تجاوز حد مصروفات الوردية البالغ {0}. المصروفات المسجلة في هذه الوردية: {1}. المسموح المتبقي: {2}",""
+"Shift Expense Limit Exceeded","تم تجاوز حد مصروفات الوردية",""
"Mode of Payment is required","طريقة الدفع مطلوبة",""
"POS Expenses","مصروفات نقطة البيع",""
"{0} expenses","{0} مصروفات",""
diff --git a/pos_next/translations/id.csv b/pos_next/translations/id.csv
index c6012d9bb..7d3d9ff7c 100644
--- a/pos_next/translations/id.csv
+++ b/pos_next/translations/id.csv
@@ -1494,3 +1494,9 @@
"{0} units available in ""{1}""","{0} unit tersedia di ""{1}""",""
"{0} updated","{0} diperbarui",""
"{0}%","{0}%",""
+"Shift limit: {0}","Batas shift: {0}",""
+"Recorded: {0}","Tercatat: {0}",""
+"Remaining: {0}","Sisa: {0}",""
+"Amount exceeds the remaining shift expense allowance of {0}","Jumlah melebihi sisa tunjangan pengeluaran shift sebesar {0}",""
+"This expense would exceed the shift expense limit of {0}. Expenses recorded this shift: {1}. Remaining allowance: {2}","Pengeluaran ini akan melebihi batas pengeluaran shift sebesar {0}. Pengeluaran yang tercatat pada shift ini: {1}. Sisa tunjangan: {2}",""
+"Shift Expense Limit Exceeded","Batas Pengeluaran Shift Terlampaui",""
diff --git a/pos_next/translations/pt-br.csv b/pos_next/translations/pt-br.csv
index de859ba11..43da7bb30 100644
--- a/pos_next/translations/pt-br.csv
+++ b/pos_next/translations/pt-br.csv
@@ -1515,3 +1515,9 @@
"variants","variantes",""
"{0} updated","{0} atualizado",""
"{0}%","{0}%",""
+"Shift limit: {0}","Limite do turno: {0}",""
+"Recorded: {0}","Registrado: {0}",""
+"Remaining: {0}","Restante: {0}",""
+"Amount exceeds the remaining shift expense allowance of {0}","O valor excede a margem restante de despesas do turno de {0}",""
+"This expense would exceed the shift expense limit of {0}. Expenses recorded this shift: {1}. Remaining allowance: {2}","Esta despesa excederia o limite de despesas do turno de {0}. Despesas registradas neste turno: {1}. Margem restante: {2}",""
+"Shift Expense Limit Exceeded","Limite de Despesa do Turno Excedido",""
From 2571b2fc98adfe000ec62e067c9e875dc13d01eb Mon Sep 17 00:00:00 2001
From: MohamedAliSmk
Date: Tue, 16 Jun 2026 15:36:30 +0300
Subject: [PATCH 3/3] Add POS Expense Report to POSNEXT Workspace
---
.../pos_next/workspace/posnext/posnext.json | 146 +++++++++---------
1 file changed, 75 insertions(+), 71 deletions(-)
diff --git a/pos_next/pos_next/workspace/posnext/posnext.json b/pos_next/pos_next/workspace/posnext/posnext.json
index 47eec8924..fde2a8608 100644
--- a/pos_next/pos_next/workspace/posnext/posnext.json
+++ b/pos_next/pos_next/workspace/posnext/posnext.json
@@ -1,6 +1,6 @@
{
"charts": [],
- "content": "[{\"id\":\"cDBfxZcI12\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"EuDVjJUKSQ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Start POS\",\"col\":3}},{\"id\":\"uXQ4aBaRfk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"jEFYB2fX3t\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Settings\",\"col\":3}},{\"id\":\"RoWvjX8Ocp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Profile\",\"col\":3}},{\"id\":\"p4KrSYzInK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Opening Shift\",\"col\":3}},{\"id\":\"VO-VLNdx_2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Closing Shift\",\"col\":3}},{\"id\":\"OYrA05uyCG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Offer\",\"col\":3}},{\"id\":\"FUd4_fFBgH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Coupon\",\"col\":3}},{\"id\":\"spacer1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"header2\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Configuration\",\"col\":12}},{\"id\":\"reports_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"pos_card\",\"type\":\"card\",\"data\":{\"card_name\":\"POS\",\"col\":4}},{\"id\":\"config_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Configuration\",\"col\":4}},{\"id\":\"shift_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Shift Management\",\"col\":4}},{\"id\":\"offers_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Offers & Coupons\",\"col\":4}},{\"id\":\"items_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Items\",\"col\":4}}]",
+ "content": "[{\"id\":\"cDBfxZcI12\",\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"col\":12}},{\"id\":\"EuDVjJUKSQ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Start POS\",\"col\":3}},{\"id\":\"uXQ4aBaRfk\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Item\",\"col\":3}},{\"id\":\"jEFYB2fX3t\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Settings\",\"col\":3}},{\"id\":\"RoWvjX8Ocp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Profile\",\"col\":3}},{\"id\":\"p4KrSYzInK\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Opening Shift\",\"col\":3}},{\"id\":\"VO-VLNdx_2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Closing Shift\",\"col\":3}},{\"id\":\"OYrA05uyCG\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Offer\",\"col\":3}},{\"id\":\"FUd4_fFBgH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Coupon\",\"col\":3}},{\"id\":\"spacer1\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"header2\",\"type\":\"header\",\"data\":{\"text\":\"Reports & Configuration\",\"col\":12}},{\"id\":\"reports_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}},{\"id\":\"config_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Configuration\",\"col\":4}},{\"id\":\"shift_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Shift Management\",\"col\":4}},{\"id\":\"offers_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Offers & Coupons\",\"col\":4}},{\"id\":\"items_card\",\"type\":\"card\",\"data\":{\"card_name\":\"Items\",\"col\":4}}]",
"creation": "2026-01-27 21:23:15.819052",
"custom_blocks": [],
"docstatus": 0,
@@ -15,76 +15,65 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "Reports",
- "link_count": 6,
+ "label": "Configuration",
+ "link_count": 2,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"hidden": 0,
- "is_query_report": 1,
- "label": "Sales vs Shifts Report",
- "link_count": 0,
- "link_to": "Sales vs Shifts Report",
- "link_type": "Report",
- "onboard": 0,
- "type": "Link"
- },
- {
- "hidden": 0,
- "is_query_report": 1,
- "label": "Cashier Performance Report",
+ "is_query_report": 0,
+ "label": "POS Settings",
"link_count": 0,
- "link_to": "Cashier Performance Report",
- "link_type": "Report",
+ "link_to": "POS Settings",
+ "link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
- "is_query_report": 1,
- "label": "Payments and Cash Control Report",
+ "is_query_report": 0,
+ "label": "POS Profile",
"link_count": 0,
- "link_to": "Payments and Cash Control Report",
- "link_type": "Report",
+ "link_to": "POS Profile",
+ "link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
- "is_query_report": 1,
- "label": "POS Expense Report",
- "link_count": 0,
- "link_to": "POS Expense Report",
- "link_type": "Report",
+ "is_query_report": 0,
+ "label": "Shift Management",
+ "link_count": 2,
+ "link_type": "DocType",
"onboard": 0,
- "type": "Link"
+ "type": "Card Break"
},
{
"hidden": 0,
- "is_query_report": 1,
- "label": "Inventory Impact and Fast Movers Report",
+ "is_query_report": 0,
+ "label": "POS Opening Shift",
"link_count": 0,
- "link_to": "Inventory Impact and Fast Movers Report",
- "link_type": "Report",
+ "link_to": "POS Opening Shift",
+ "link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
- "is_query_report": 1,
- "label": "Offline Sync and System Health Report",
+ "is_query_report": 0,
+ "label": "POS Closing Shift",
"link_count": 0,
- "link_to": "Offline Sync and System Health Report",
- "link_type": "Report",
+ "link_to": "POS Closing Shift",
+ "link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
- "label": "Configuration",
+ "label": "Offers & Coupons",
"link_count": 2,
"link_type": "DocType",
"onboard": 0,
@@ -93,9 +82,9 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "POS Settings",
+ "label": "POS Offer",
"link_count": 0,
- "link_to": "POS Settings",
+ "link_to": "POS Offer",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
@@ -103,9 +92,9 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "POS Profile",
+ "label": "POS Coupon",
"link_count": 0,
- "link_to": "POS Profile",
+ "link_to": "POS Coupon",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
@@ -113,8 +102,8 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "Shift Management",
- "link_count": 2,
+ "label": "Items",
+ "link_count": 1,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
@@ -122,9 +111,9 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "POS Opening Shift",
+ "label": "Item",
"link_count": 0,
- "link_to": "POS Opening Shift",
+ "link_to": "Item",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
@@ -132,63 +121,74 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "POS Closing Shift",
- "link_count": 0,
- "link_to": "POS Closing Shift",
+ "label": "Reports",
+ "link_count": 6,
"link_type": "DocType",
"onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Sales vs Shifts Report",
+ "link_count": 0,
+ "link_to": "Sales vs Shifts Report",
+ "link_type": "Report",
+ "onboard": 0,
"type": "Link"
},
{
"hidden": 0,
- "is_query_report": 0,
- "label": "Offers & Coupons",
- "link_count": 2,
- "link_type": "DocType",
+ "is_query_report": 1,
+ "label": "Cashier Performance Report",
+ "link_count": 0,
+ "link_to": "Cashier Performance Report",
+ "link_type": "Report",
"onboard": 0,
- "type": "Card Break"
+ "type": "Link"
},
{
"hidden": 0,
- "is_query_report": 0,
- "label": "POS Offer",
+ "is_query_report": 1,
+ "label": "Payments and Cash Control Report",
"link_count": 0,
- "link_to": "POS Offer",
- "link_type": "DocType",
+ "link_to": "Payments and Cash Control Report",
+ "link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
- "is_query_report": 0,
- "label": "POS Coupon",
+ "is_query_report": 1,
+ "label": "Inventory Impact and Fast Movers Report",
"link_count": 0,
- "link_to": "POS Coupon",
- "link_type": "DocType",
+ "link_to": "Inventory Impact and Fast Movers Report",
+ "link_type": "Report",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
- "is_query_report": 0,
- "label": "Items",
- "link_count": 1,
- "link_type": "DocType",
+ "is_query_report": 1,
+ "label": "Offline Sync and System Health Report",
+ "link_count": 0,
+ "link_to": "Offline Sync and System Health Report",
+ "link_type": "Report",
"onboard": 0,
- "type": "Card Break"
+ "type": "Link"
},
{
"hidden": 0,
- "is_query_report": 0,
- "label": "Item",
+ "is_query_report": 1,
+ "label": "POS Expense Report",
"link_count": 0,
- "link_to": "Item",
- "link_type": "DocType",
+ "link_to": "POS Expense Report",
+ "link_type": "Report",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2026-06-03 15:43:52.445464",
+ "modified": "2026-06-16 15:35:30.559208",
"modified_by": "Administrator",
"module": "POS Next",
"name": "POSNext",
@@ -196,7 +196,11 @@
"owner": "Administrator",
"public": 1,
"quick_lists": [],
- "roles": [],
+ "roles": [
+ {
+ "role": "Nexus POS Manager"
+ }
+ ],
"sequence_id": 0.0,
"shortcuts": [
{