diff --git a/pos_next/api/sales_invoice_hooks.py b/pos_next/api/sales_invoice_hooks.py index 10304f89..75a3d16e 100644 --- a/pos_next/api/sales_invoice_hooks.py +++ b/pos_next/api/sales_invoice_hooks.py @@ -141,3 +141,35 @@ def before_cancel(doc, method=None): alert=True, indicator="orange" ) + + +def on_cancel(doc, method=None): + """ + On Cancel hook for Sales Invoice. + Roll back coupon usage only after the invoice has cancelled successfully. + + Args: + doc: Sales Invoice document + method: Hook method name (unused) + """ + rollback_coupon_usage(doc) + + +def rollback_coupon_usage(doc): + """Restore coupon usage for cancelled invoices that consumed a POS coupon.""" + if doc.get("is_return"): + return + + coupon_code = doc.get("coupon_code") + if not coupon_code or not frappe.db.table_exists("POS Coupon"): + return + + try: + from pos_next.pos_next.doctype.pos_coupon.pos_coupon import decrement_coupon_usage + + decrement_coupon_usage(coupon_code) + except Exception as e: + frappe.log_error( + title="Coupon Usage Rollback Failed", + message=f"Invoice: {doc.name}, Coupon: {coupon_code}, Error: {str(e)}\n{frappe.get_traceback()}", + ) diff --git a/pos_next/api/test_sales_invoice_hooks.py b/pos_next/api/test_sales_invoice_hooks.py new file mode 100644 index 00000000..15f2ce90 --- /dev/null +++ b/pos_next/api/test_sales_invoice_hooks.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt + +import unittest +from unittest.mock import Mock, patch + +from pos_next.api.sales_invoice_hooks import on_cancel, rollback_coupon_usage + + +class TestSalesInvoiceHooks(unittest.TestCase): + @patch("pos_next.api.sales_invoice_hooks.frappe.db") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.decrement_coupon_usage") + def test_rollback_coupon_usage_for_cancelled_invoice(self, mock_decrement, mock_db): + doc = Mock() + doc.get.side_effect = lambda key, default=None: { + "is_return": 0, + "coupon_code": "SAVE10", + }.get(key, default) + doc.name = "ACC-SINV-0001" + mock_db.table_exists.return_value = True + + rollback_coupon_usage(doc) + + mock_decrement.assert_called_once_with("SAVE10") + + @patch("pos_next.api.sales_invoice_hooks.frappe.db") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.decrement_coupon_usage") + def test_rollback_coupon_usage_skips_invoices_without_coupon(self, mock_decrement, mock_db): + doc = Mock() + doc.get.side_effect = lambda key, default=None: { + "is_return": 0, + "coupon_code": None, + }.get(key, default) + + rollback_coupon_usage(doc) + + mock_db.table_exists.assert_not_called() + mock_decrement.assert_not_called() + + @patch("pos_next.api.sales_invoice_hooks.frappe.db") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.decrement_coupon_usage") + def test_rollback_coupon_usage_skips_return_invoices(self, mock_decrement, mock_db): + doc = Mock() + doc.get.side_effect = lambda key, default=None: { + "is_return": 1, + "coupon_code": "SAVE10", + }.get(key, default) + + rollback_coupon_usage(doc) + + mock_db.table_exists.assert_not_called() + mock_decrement.assert_not_called() + + @patch("pos_next.api.sales_invoice_hooks.frappe.db") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.decrement_coupon_usage") + def test_rollback_coupon_usage_skips_when_coupon_table_missing(self, mock_decrement, mock_db): + doc = Mock() + doc.get.side_effect = lambda key, default=None: { + "is_return": 0, + "coupon_code": "SAVE10", + }.get(key, default) + mock_db.table_exists.return_value = False + + rollback_coupon_usage(doc) + + mock_decrement.assert_not_called() + + @patch("pos_next.api.sales_invoice_hooks.rollback_coupon_usage") + def test_on_cancel_delegates_to_coupon_rollback(self, mock_rollback): + doc = Mock() + + on_cancel(doc) + + mock_rollback.assert_called_once_with(doc) diff --git a/pos_next/hooks.py b/pos_next/hooks.py index 7b422cf4..8f40e73e 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -190,7 +190,10 @@ "pos_next.realtime_events.emit_stock_update_event", "pos_next.api.wallet.process_loyalty_to_wallet" ], - "on_cancel": "pos_next.realtime_events.emit_stock_update_event", + "on_cancel": [ + "pos_next.api.sales_invoice_hooks.on_cancel", + "pos_next.realtime_events.emit_stock_update_event", + ], "after_insert": "pos_next.realtime_events.emit_invoice_created_event" }, "POS Profile": { @@ -294,4 +297,4 @@ # } -website_route_rules = [{'from_route': '/pos/', 'to_route': 'pos'},] \ No newline at end of file +website_route_rules = [{'from_route': '/pos/', 'to_route': 'pos'},]