diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 5f96d3e4..bca2d9d2 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -1302,14 +1302,13 @@ def submit_invoice(invoice=None, data=None): if coupon_code: # Increment usage counter for POS Coupon if frappe.db.table_exists("POS Coupon"): - try: - from pos_next.pos_next.doctype.pos_coupon.pos_coupon import increment_coupon_usage - increment_coupon_usage(coupon_code) - except Exception as e: - frappe.log_error( - title="Failed to increment coupon usage", - message=f"Coupon: {coupon_code}, Error: {str(e)}" - ) + from pos_next.pos_next.doctype.pos_coupon.pos_coupon import consume_coupon_usage + + consume_coupon_usage( + coupon_code, + customer=invoice_doc.customer, + company=invoice_doc.company, + ) # Auto-set batch numbers for returns _auto_set_return_batches(invoice_doc) diff --git a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py index 92bee49b..4a4b1a04 100644 --- a/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py +++ b/pos_next/pos_next/doctype/pos_coupon/pos_coupon.py @@ -176,18 +176,44 @@ def apply_coupon_discount(coupon, cart_total, net_total=None): } -def increment_coupon_usage(coupon_code): - """Increment the usage counter for a coupon""" +def consume_coupon_usage(coupon_code, customer=None, company=None): + """Atomically validate and increment coupon usage within the current transaction.""" + coupon_code = coupon_code.upper() + locked_coupon = frappe.db.sql( + """ + SELECT name + FROM `tabPOS Coupon` + WHERE coupon_code = %s + FOR UPDATE + """, + (coupon_code,), + as_dict=True, + ) + + if not locked_coupon: + frappe.throw(_("Sorry, this coupon code does not exist")) + + coupon_result = check_coupon_code(coupon_code, customer=customer, company=company) + if not coupon_result or not coupon_result.get("valid"): + frappe.throw(_(coupon_result.get("msg", "Invalid coupon code"))) + + coupon = coupon_result["coupon"] + updated_used = (coupon.used or 0) + 1 + frappe.db.set_value("POS Coupon", coupon.name, "used", updated_used, update_modified=False) + coupon.used = updated_used + return coupon + + +def increment_coupon_usage(coupon_code, customer=None, company=None): + """Backward-compatible wrapper around atomic coupon consumption.""" try: - coupon = frappe.get_doc("POS Coupon", {"coupon_code": coupon_code.upper()}) - coupon.used = (coupon.used or 0) + 1 - coupon.db_set('used', coupon.used) - frappe.db.commit() + return consume_coupon_usage(coupon_code, customer=customer, company=company) except Exception as e: frappe.log_error( title="Coupon Usage Increment Failed", message=f"Failed to increment usage for coupon {coupon_code}: {str(e)}" ) + raise def decrement_coupon_usage(coupon_code): diff --git a/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py b/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py index 85e1e3bb..e358061a 100644 --- a/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py +++ b/pos_next/pos_next/doctype/pos_coupon/test_pos_coupon.py @@ -6,6 +6,7 @@ from pos_next.pos_next.doctype.pos_coupon.pos_coupon import ( _get_customer_coupon_usage_count, + consume_coupon_usage, ) @@ -53,3 +54,30 @@ def test_one_use_coupon_skips_doctypes_without_coupon_field(self, mock_db, mock_ "Sales Invoice", filters={"customer": "Customer A", "coupon_code": "SAVE10", "docstatus": 1}, ) + + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.check_coupon_code") + @patch("pos_next.pos_next.doctype.pos_coupon.pos_coupon.frappe.db") + def test_consume_coupon_usage_locks_and_updates_usage(self, mock_db, mock_check_coupon_code): + coupon = Mock() + coupon.name = "PROMO-0001" + coupon.used = 2 + + mock_db.sql.return_value = [{"name": coupon.name}] + mock_check_coupon_code.return_value = {"valid": True, "coupon": coupon} + + result = consume_coupon_usage("promo10", customer="Customer A", company="Test Co") + + self.assertEqual(result.used, 3) + mock_db.sql.assert_called_once() + mock_check_coupon_code.assert_called_once_with( + "PROMO10", + customer="Customer A", + company="Test Co", + ) + mock_db.set_value.assert_called_once_with( + "POS Coupon", + "PROMO-0001", + "used", + 3, + update_modified=False, + )