From 89333afe93a02d25754829ddb753287405962c50 Mon Sep 17 00:00:00 2001 From: MT Date: Sat, 28 Mar 2026 17:55:14 +0200 Subject: [PATCH 1/2] fix: scope wallet reservations to draft invoices --- pos_next/api/test_wallet.py | 89 ++++++++++++++++++++++ pos_next/api/wallet.py | 18 +++-- pos_next/pos_next/doctype/wallet/wallet.py | 25 +++--- 3 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 pos_next/api/test_wallet.py diff --git a/pos_next/api/test_wallet.py b/pos_next/api/test_wallet.py new file mode 100644 index 00000000..954ce5d5 --- /dev/null +++ b/pos_next/api/test_wallet.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt + +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from pos_next.api import wallet as api_wallet +from pos_next.pos_next.doctype.wallet import wallet as wallet_doctype + + +class TestWalletAPI(unittest.TestCase): + @patch("pos_next.api.wallet.frappe.db") + @patch("pos_next.api.wallet.frappe.get_all") + def test_get_pending_wallet_payments_only_counts_draft_wallet_payments_in_same_company( + self, + mock_get_all, + mock_db, + ): + mock_get_all.side_effect = [ + [ + SimpleNamespace(name="DRAFT-SAME"), + SimpleNamespace(name="EXCLUDED-DRAFT"), + ], + [ + SimpleNamespace(mode_of_payment="Wallet", amount=40), + SimpleNamespace(mode_of_payment="Cash", amount=10), + ], + ] + mock_db.get_value.side_effect = lambda doctype, name, field: 1 if name == "Wallet" else 0 + + result = api_wallet.get_pending_wallet_payments( + "Guest", + exclude_invoice="EXCLUDED-DRAFT", + company="Sonex", + ) + + self.assertEqual(result, 40) + self.assertEqual(mock_get_all.call_count, 2) + self.assertEqual( + mock_get_all.call_args_list[0].kwargs["filters"], + { + "customer": "Guest", + "docstatus": 0, + "outstanding_amount": [">", 0], + "is_pos": 1, + "company": "Sonex", + }, + ) + + @patch("pos_next.pos_next.doctype.wallet.wallet.get_pending_wallet_payments") + def test_wallet_get_available_balance_passes_company_to_pending_reservations(self, mock_pending): + mock_pending.return_value = 25 + wallet = Mock() + wallet.customer = "Guest" + wallet.company = "Sonex" + wallet.get_balance.return_value = 100 + + result = wallet_doctype.Wallet.get_available_balance(wallet) + + self.assertEqual(result, 75) + mock_pending.assert_called_once_with("Guest", company="Sonex") + + @patch("pos_next.pos_next.doctype.wallet.wallet.frappe.db") + @patch("pos_next.pos_next.doctype.wallet.wallet.frappe.get_all") + def test_doctype_pending_wallet_payments_uses_draft_only_and_company_scope( + self, + mock_get_all, + mock_db, + ): + mock_get_all.side_effect = [ + [SimpleNamespace(name="DRAFT-SAME")], + [SimpleNamespace(mode_of_payment="Wallet", amount=55)], + ] + mock_db.get_value.return_value = 1 + + result = wallet_doctype.get_pending_wallet_payments("Guest", company="Sonex") + + self.assertEqual(result, 55) + self.assertEqual( + mock_get_all.call_args_list[0].kwargs["filters"], + { + "customer": "Guest", + "docstatus": 0, + "outstanding_amount": [">", 0], + "is_pos": 1, + "company": "Sonex", + }, + ) diff --git a/pos_next/api/wallet.py b/pos_next/api/wallet.py index 6e3e9e5d..4b09756f 100644 --- a/pos_next/api/wallet.py +++ b/pos_next/api/wallet.py @@ -201,8 +201,12 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): # Negate because negative receivable balance = positive wallet credit wallet_balance = -flt(gl_balance) - # Subtract pending wallet payments from open POS invoices - pending_wallet_amount = get_pending_wallet_payments(customer, exclude_invoice) + # Subtract wallet payments only from draft POS invoices in the same company. + pending_wallet_amount = get_pending_wallet_payments( + customer, + exclude_invoice=exclude_invoice, + company=company, + ) available_balance = flt(wallet_balance) - flt(pending_wallet_amount) @@ -213,16 +217,18 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): return 0.0 -def get_pending_wallet_payments(customer, exclude_invoice=None): +def get_pending_wallet_payments(customer, exclude_invoice=None, company=None): """ - Get total wallet payments from unconsolidated/pending POS invoices. + Get total wallet payments from draft POS invoices that still reserve wallet balance. """ filters = { "customer": customer, - "docstatus": ["in", [0, 1]], + "docstatus": 0, "outstanding_amount": [">", 0], - "is_pos": 1 + "is_pos": 1, } + if company: + filters["company"] = company invoices = frappe.get_all( "Sales Invoice", diff --git a/pos_next/pos_next/doctype/wallet/wallet.py b/pos_next/pos_next/doctype/wallet/wallet.py index 77bc3379..dc9a769b 100644 --- a/pos_next/pos_next/doctype/wallet/wallet.py +++ b/pos_next/pos_next/doctype/wallet/wallet.py @@ -54,7 +54,7 @@ def get_balance(self): def get_available_balance(self): """Get available balance (current balance minus pending wallet payments)""" current = self.get_balance() - pending = get_pending_wallet_payments(self.customer) + pending = get_pending_wallet_payments(self.customer, company=self.company) available = flt(current) - flt(pending) return available if available > 0 else 0.0 @@ -120,8 +120,12 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): # Negate because negative receivable balance = positive wallet credit wallet_balance = -flt(gl_balance) - # Subtract pending wallet payments from open POS invoices - pending_wallet_amount = get_pending_wallet_payments(customer, exclude_invoice) + # Subtract wallet payments only from draft POS invoices in the same company. + pending_wallet_amount = get_pending_wallet_payments( + customer, + exclude_invoice=exclude_invoice, + company=company, + ) available_balance = flt(wallet_balance) - flt(pending_wallet_amount) @@ -132,18 +136,20 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): return 0.0 -def get_pending_wallet_payments(customer, exclude_invoice=None): +def get_pending_wallet_payments(customer, exclude_invoice=None, company=None): """ - Get total wallet payments from unconsolidated/pending POS invoices. - This prevents double-spending of wallet balance. + Get total wallet payments from draft POS invoices. + This prevents double-spending of wallet balance without double-counting + submitted invoices that are already reflected in GL. """ - # Get open Sales Invoices (draft or unconsolidated POS invoices) filters = { "customer": customer, - "docstatus": ["in", [0, 1]], # Draft or Submitted + "docstatus": 0, "outstanding_amount": [">", 0], - "is_pos": 1 + "is_pos": 1, } + if company: + filters["company"] = company invoices = frappe.get_all( "Sales Invoice", @@ -157,7 +163,6 @@ def get_pending_wallet_payments(customer, exclude_invoice=None): if exclude_invoice and invoice.name == exclude_invoice: continue - # Get wallet payments from this invoice payments = frappe.get_all( "Sales Invoice Payment", filters={"parent": invoice.name}, From c4272cb4309c82a97527426a5100b465908473ce Mon Sep 17 00:00:00 2001 From: MT Date: Fri, 3 Apr 2026 23:55:41 +0200 Subject: [PATCH 2/2] fix: consolidate wallet reservation logic --- pos_next/api/test_wallet.py | 55 ++++++++------ pos_next/api/wallet.py | 24 +++--- pos_next/pos_next/doctype/wallet/wallet.py | 86 +++++----------------- 3 files changed, 62 insertions(+), 103 deletions(-) diff --git a/pos_next/api/test_wallet.py b/pos_next/api/test_wallet.py index 954ce5d5..8f39ce8d 100644 --- a/pos_next/api/test_wallet.py +++ b/pos_next/api/test_wallet.py @@ -12,8 +12,10 @@ class TestWalletAPI(unittest.TestCase): @patch("pos_next.api.wallet.frappe.db") @patch("pos_next.api.wallet.frappe.get_all") + @patch("pos_next.api.wallet._is_wallet_payment_mode") def test_get_pending_wallet_payments_only_counts_draft_wallet_payments_in_same_company( self, + mock_is_wallet_payment_mode, mock_get_all, mock_db, ): @@ -27,7 +29,7 @@ def test_get_pending_wallet_payments_only_counts_draft_wallet_payments_in_same_c SimpleNamespace(mode_of_payment="Cash", amount=10), ], ] - mock_db.get_value.side_effect = lambda doctype, name, field: 1 if name == "Wallet" else 0 + mock_is_wallet_payment_mode.side_effect = lambda mode: mode == "Wallet" result = api_wallet.get_pending_wallet_payments( "Guest", @@ -61,29 +63,36 @@ def test_wallet_get_available_balance_passes_company_to_pending_reservations(sel self.assertEqual(result, 75) mock_pending.assert_called_once_with("Guest", company="Sonex") - @patch("pos_next.pos_next.doctype.wallet.wallet.frappe.db") - @patch("pos_next.pos_next.doctype.wallet.wallet.frappe.get_all") - def test_doctype_pending_wallet_payments_uses_draft_only_and_company_scope( - self, - mock_get_all, - mock_db, - ): - mock_get_all.side_effect = [ - [SimpleNamespace(name="DRAFT-SAME")], - [SimpleNamespace(mode_of_payment="Wallet", amount=55)], - ] - mock_db.get_value.return_value = 1 + @patch("pos_next.api.wallet.get_pending_wallet_payments") + def test_doctype_get_pending_wallet_payments_delegates_to_api_wrapper(self, mock_pending): + mock_pending.return_value = 55 - result = wallet_doctype.get_pending_wallet_payments("Guest", company="Sonex") + result = wallet_doctype.get_pending_wallet_payments( + "Guest", + exclude_invoice="INV-0001", + company="Sonex", + ) self.assertEqual(result, 55) - self.assertEqual( - mock_get_all.call_args_list[0].kwargs["filters"], - { - "customer": "Guest", - "docstatus": 0, - "outstanding_amount": [">", 0], - "is_pos": 1, - "company": "Sonex", - }, + mock_pending.assert_called_once_with( + "Guest", + exclude_invoice="INV-0001", + company="Sonex", + ) + + @patch("pos_next.api.wallet.get_customer_wallet_balance") + def test_doctype_get_customer_wallet_balance_delegates_to_api_wrapper(self, mock_balance): + mock_balance.return_value = 120 + + result = wallet_doctype.get_customer_wallet_balance( + "Guest", + company="Sonex", + exclude_invoice="INV-0002", + ) + + self.assertEqual(result, 120) + mock_balance.assert_called_once_with( + "Guest", + company="Sonex", + exclude_invoice="INV-0002", ) diff --git a/pos_next/api/wallet.py b/pos_next/api/wallet.py index 4b09756f..41413a89 100644 --- a/pos_next/api/wallet.py +++ b/pos_next/api/wallet.py @@ -11,6 +11,17 @@ from frappe.utils import flt, cint +def _is_wallet_payment_mode(mode_of_payment): + """Return whether the mode of payment is configured as a wallet payment.""" + return cint( + frappe.db.get_value( + "Mode of Payment", + mode_of_payment, + "is_wallet_payment", + ) + ) + + def validate_wallet_payment(doc, method=None): """ Validate wallet payment on Sales Invoice. @@ -150,13 +161,7 @@ def get_wallet_amount_from_payments(payments): if not payment.mode_of_payment: continue - is_wallet = frappe.db.get_value( - "Mode of Payment", - payment.mode_of_payment, - "is_wallet_payment" - ) - - if is_wallet: + if _is_wallet_payment_mode(payment.mode_of_payment): wallet_amount += flt(payment.amount) return wallet_amount @@ -249,10 +254,7 @@ def get_pending_wallet_payments(customer, exclude_invoice=None, company=None): ) for payment in payments: - is_wallet = frappe.db.get_value( - "Mode of Payment", payment.mode_of_payment, "is_wallet_payment" - ) - if is_wallet: + if _is_wallet_payment_mode(payment.mode_of_payment): pending_amount += flt(payment.amount) return pending_amount diff --git a/pos_next/pos_next/doctype/wallet/wallet.py b/pos_next/pos_next/doctype/wallet/wallet.py index dc9a769b..f3367f0a 100644 --- a/pos_next/pos_next/doctype/wallet/wallet.py +++ b/pos_next/pos_next/doctype/wallet/wallet.py @@ -100,84 +100,32 @@ def get_customer_wallet_balance(customer, company=None, exclude_invoice=None): Returns: float: Available wallet balance """ - try: - filters = {"customer": customer, "status": "Active"} - if company: - filters["company"] = company - - wallet = frappe.db.get_value("Wallet", filters, ["name", "account"], as_dict=True) - - if not wallet: - return 0.0 - - # Get balance from GL entries - gl_balance = get_balance_on( - account=wallet.account, - party_type="Customer", - party=customer - ) - - # Negate because negative receivable balance = positive wallet credit - wallet_balance = -flt(gl_balance) - - # Subtract wallet payments only from draft POS invoices in the same company. - pending_wallet_amount = get_pending_wallet_payments( - customer, - exclude_invoice=exclude_invoice, - company=company, - ) - - available_balance = flt(wallet_balance) - flt(pending_wallet_amount) - - return available_balance if available_balance > 0 else 0.0 - - except Exception: - frappe.log_error(frappe.get_traceback(), "Wallet Balance Error") - return 0.0 + from pos_next.api.wallet import get_customer_wallet_balance as api_get_customer_wallet_balance + + # Keep a thin wrapper here for backwards compatibility while delegating to the + # API implementation so pending-wallet logic stays in one place. + return api_get_customer_wallet_balance( + customer, + company=company, + exclude_invoice=exclude_invoice, + ) def get_pending_wallet_payments(customer, exclude_invoice=None, company=None): """ Get total wallet payments from draft POS invoices. - This prevents double-spending of wallet balance without double-counting - submitted invoices that are already reflected in GL. + + Keep a wrapper here for backwards compatibility while delegating to the API + implementation so the draft/company reservation rules do not diverge. """ - filters = { - "customer": customer, - "docstatus": 0, - "outstanding_amount": [">", 0], - "is_pos": 1, - } - if company: - filters["company"] = company + from pos_next.api.wallet import get_pending_wallet_payments as api_get_pending_wallet_payments - invoices = frappe.get_all( - "Sales Invoice", - filters=filters, - fields=["name"] + return api_get_pending_wallet_payments( + customer, + exclude_invoice=exclude_invoice, + company=company, ) - pending_amount = 0.0 - - for invoice in invoices: - if exclude_invoice and invoice.name == exclude_invoice: - continue - - payments = frappe.get_all( - "Sales Invoice Payment", - filters={"parent": invoice.name}, - fields=["mode_of_payment", "amount"] - ) - - for payment in payments: - is_wallet = frappe.db.get_value( - "Mode of Payment", payment.mode_of_payment, "is_wallet_payment" - ) - if is_wallet: - pending_amount += flt(payment.amount) - - return pending_amount - @frappe.whitelist() def create_customer_wallet(customer, company, account=None):