diff --git a/pos_next/api/test_wallet.py b/pos_next/api/test_wallet.py new file mode 100644 index 00000000..8f39ce8d --- /dev/null +++ b/pos_next/api/test_wallet.py @@ -0,0 +1,98 @@ +# 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") + @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, + ): + 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_is_wallet_payment_mode.side_effect = lambda mode: mode == "Wallet" + + 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.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", + exclude_invoice="INV-0001", + company="Sonex", + ) + + self.assertEqual(result, 55) + 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 6e3e9e5d..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 @@ -201,8 +206,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 +222,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", @@ -243,10 +254,7 @@ def get_pending_wallet_payments(customer, exclude_invoice=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 77bc3379..f3367f0a 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 @@ -100,78 +100,31 @@ 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 pending wallet payments from open POS invoices - pending_wallet_amount = get_pending_wallet_payments(customer, exclude_invoice) - - 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 - - -def get_pending_wallet_payments(customer, exclude_invoice=None): - """ - Get total wallet payments from unconsolidated/pending POS invoices. - This prevents double-spending of wallet balance. - """ - # Get open Sales Invoices (draft or unconsolidated POS invoices) - filters = { - "customer": customer, - "docstatus": ["in", [0, 1]], # Draft or Submitted - "outstanding_amount": [">", 0], - "is_pos": 1 - } - - invoices = frappe.get_all( - "Sales Invoice", - filters=filters, - fields=["name"] + 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, ) - pending_amount = 0.0 - for invoice in invoices: - 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}, - fields=["mode_of_payment", "amount"] - ) +def get_pending_wallet_payments(customer, exclude_invoice=None, company=None): + """ + Get total wallet payments from draft POS invoices. - 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) + Keep a wrapper here for backwards compatibility while delegating to the API + implementation so the draft/company reservation rules do not diverge. + """ + from pos_next.api.wallet import get_pending_wallet_payments as api_get_pending_wallet_payments - return pending_amount + return api_get_pending_wallet_payments( + customer, + exclude_invoice=exclude_invoice, + company=company, + ) @frappe.whitelist()