Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions pos_next/api/test_wallet.py
Original file line number Diff line number Diff line change
@@ -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",
)
42 changes: 25 additions & 17 deletions pos_next/api/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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",
Expand All @@ -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
Expand Down
89 changes: 21 additions & 68 deletions pos_next/pos_next/doctype/wallet/wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
Loading