From 633ebb41aca33ec7575269d8bd53b1c8e4c20351 Mon Sep 17 00:00:00 2001 From: ervishnucs Date: Thu, 27 Aug 2026 15:30:24 +0530 Subject: [PATCH] chore: resolve backport conflicts for #58430 --- .../payment_request/payment_request.py | 16 - .../payment_request/test_payment_request.py | 1069 ----------------- 2 files changed, 1085 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 66769a75ada..d9c8ca3307d 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -1074,21 +1074,6 @@ def get_dummy_message(doc): @frappe.whitelist() -<<<<<<< HEAD -def get_subscription_details(reference_doctype, reference_name): - if reference_doctype == "Sales Invoice": - subscriptions = frappe.db.sql( - """SELECT parent as sub_name FROM `tabSubscription Invoice` WHERE invoice=%s""", - reference_name, - as_dict=1, - ) - subscription_plans = [] - for subscription in subscriptions: - plans = frappe.get_doc("Subscription", subscription.sub_name).plans - for plan in plans: - subscription_plans.append(plan) - return subscription_plans -======= def get_subscription_details(reference_doctype: str, reference_name: str): if reference_doctype != "Sales Invoice": return [] @@ -1108,7 +1093,6 @@ def get_subscription_details(reference_doctype: str, reference_name: str): ) return subscription_plan ->>>>>>> 1b81db4 (fix/payment-request-subscription-plans-population (#57494)) @frappe.whitelist() diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 5a3794fa67d..47b8043c78c 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -14,15 +14,9 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice -from erpnext.accounts.doctype.subscription.test_subscription import ( - create_plan, - create_subscription, - make_plans, -) from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.setup.utils import get_exchange_rate -from erpnext.stock.doctype.item.test_item import make_item from erpnext.tests.utils import ERPNextTestSuite PAYMENT_URL = "https://example.com/payment" @@ -1007,1066 +1001,3 @@ class TestPaymentRequest(ERPNextTestSuite): return_doc=True, schedules=schedules, ) -<<<<<<< HEAD -======= - - -class TestPaymentRequestV2Gateway(ERPNextTestSuite): - """Tests for PaymentController v2 gateway integration.""" - - def setUp(self): - """Set up payment gateway fixtures for flow tests.""" - for payment_gateway in payment_gateways: - if not frappe.db.get_value("Payment Gateway", payment_gateway["gateway"], "name"): - frappe.get_doc(payment_gateway).insert(ignore_permissions=True) - for method in payment_method: - if not frappe.db.get_value( - "Payment Gateway Account", - {"payment_gateway": method["payment_gateway"], "currency": method["currency"]}, - "name", - ): - frappe.get_doc(method).insert(ignore_permissions=True) - - def _mock_payments_modules(self, is_v2_gateway_return_value): - """Helper to mock both payments and payments.utils modules. - - Python requires the parent module to be in sys.modules before importing - a child module. This helper ensures both are mocked properly. - """ - mock_payments = MagicMock() - mock_utils = MagicMock() - mock_utils.is_v2_gateway = MagicMock(return_value=is_v2_gateway_return_value) - mock_payments.utils = mock_utils - return {"payments": mock_payments, "payments.utils": mock_utils}, mock_utils - - def test_is_v2_gateway_returns_false_for_none(self): - """_is_v2_gateway returns False for None input.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - # Mock returns True, but is_v2_gateway(None) in payments.utils returns False - modules, mock_utils = self._mock_payments_modules(False) - - with patch.dict(sys.modules, modules): - result = _is_v2_gateway(None) - self.assertFalse(result) - mock_utils.is_v2_gateway.assert_called_once_with(None) - - def test_is_v2_gateway_returns_false_for_empty_string(self): - """_is_v2_gateway returns False for empty string input.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - modules, mock_utils = self._mock_payments_modules(False) - - with patch.dict(sys.modules, modules): - result = _is_v2_gateway("") - self.assertFalse(result) - mock_utils.is_v2_gateway.assert_called_once_with("") - - def test_is_v2_gateway_returns_false_for_nonexistent_gateway(self): - """_is_v2_gateway returns False for nonexistent gateway.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - modules, mock_utils = self._mock_payments_modules(False) - - with patch.dict(sys.modules, modules): - result = _is_v2_gateway("NonExistentGateway12345") - self.assertFalse(result) - mock_utils.is_v2_gateway.assert_called_once_with("NonExistentGateway12345") - - def test_is_v2_gateway_delegates_to_payments_util(self): - """_is_v2_gateway delegates to payments.utils.is_v2_gateway.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - modules, mock_utils = self._mock_payments_modules(True) - - with patch.dict(sys.modules, modules): - result = _is_v2_gateway("_Test Gateway") - self.assertTrue(result) - mock_utils.is_v2_gateway.assert_called_once_with("_Test Gateway") - - def test_is_v2_gateway_returns_false_when_payments_util_returns_false(self): - """_is_v2_gateway returns False when payments.utils.is_v2_gateway returns False.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - modules, _ = self._mock_payments_modules(False) - - with patch.dict(sys.modules, modules): - result = _is_v2_gateway("_Test Gateway") - self.assertFalse(result) - - def test_is_v2_gateway_catches_unexpected_exceptions(self): - """_is_v2_gateway catches unexpected exceptions and returns False.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - mock_payments = MagicMock() - mock_utils = MagicMock() - mock_utils.is_v2_gateway = MagicMock(side_effect=RuntimeError("Unexpected error")) - mock_payments.utils = mock_utils - modules = {"payments": mock_payments, "payments.utils": mock_utils} - - with patch.dict(sys.modules, modules): - # Should not raise, should return False - result = _is_v2_gateway("_Test Gateway") - self.assertFalse(result) - - def test_get_tx_data_returns_required_fields(self): - """get_tx_data returns all fields required by TxData.""" - so = make_sales_order(currency="INR") - - # Use make_payment_request to properly populate all mandatory fields - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - tx_data = pr.get_tx_data() - - # Verify all required TxData fields are present - self.assertIn("amount", tx_data) - self.assertIn("currency", tx_data) - self.assertIn("reference_doctype", tx_data) - self.assertIn("reference_docname", tx_data) - self.assertIn("payer_contact", tx_data) - self.assertIn("payer_address", tx_data) - self.assertIn("loyalty_points", tx_data) - self.assertIn("discount_amount", tx_data) - - # Verify values - amount should come from get_request_amount() - self.assertEqual(tx_data.amount, pr.get_request_amount()) - self.assertEqual(tx_data.currency, "INR") - self.assertEqual(tx_data.reference_doctype, "Payment Request") - self.assertEqual(tx_data.reference_docname, pr.name) - - def test_get_tx_data_uses_request_amount_not_grand_total(self): - """get_tx_data should use get_request_amount() to support partial payments.""" - so = make_sales_order(currency="INR", qty=1, rate=1000) - - # Use make_payment_request to properly populate all mandatory fields - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - # Mock get_request_amount to return a partial amount - with patch.object(pr, "get_request_amount", return_value=500.0): - tx_data = pr.get_tx_data() - # Amount should be the partial request amount, not grand_total - self.assertEqual(tx_data.amount, 500.0) - self.assertNotEqual(tx_data.amount, pr.grand_total) - - def test_get_party_contact_and_address_returns_whitelisted_fields_only(self): - """_get_party_contact_and_address should only return payment-relevant fields.""" - # Create a customer with contact and address to ensure assertions run - customer = frappe.get_doc("Customer", "_Test Customer") - - # Create or get test contact - contact_name = f"_Test Contact for {customer.name}" - if not frappe.db.exists("Contact", contact_name): - test_contact = frappe.get_doc( - { - "doctype": "Contact", - "first_name": "Test", - "last_name": "Contact", - "email_id": "test.contact@example.com", - "phone": "+1234567890", - } - ) - test_contact.insert(ignore_permissions=True) - contact_name = test_contact.name - - # Create or get test address - address_name = f"_Test Address for {customer.name}" - if not frappe.db.exists("Address", {"address_title": address_name}): - test_address = frappe.get_doc( - { - "doctype": "Address", - "address_title": address_name, - "address_type": "Billing", - "address_line1": "123 Test Street", - "city": "Test City", - "country": "United States", - } - ) - test_address.insert(ignore_permissions=True) - address_name = test_address.name - - # Link contact and address to customer - customer.customer_primary_contact = contact_name - customer.customer_primary_address = address_name - customer.save(ignore_permissions=True) - - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - contact, address = pr._get_party_contact_and_address() - - # Assertions should always run now that we've ensured contact/address exist - self.assertTrue(contact, "Contact should be returned") - allowed_contact_fields = {"first_name", "last_name", "email_id", "email", "phone"} - self.assertTrue(set(contact.keys()).issubset(allowed_contact_fields)) - # Verify internal/sensitive fields are NOT present - self.assertNotIn("owner", contact) - self.assertNotIn("creation", contact) - self.assertNotIn("modified", contact) - self.assertNotIn("doctype", contact) - - self.assertTrue(address, "Address should be returned") - allowed_address_fields = {"address_line1", "address_line2", "city", "state", "pincode", "country"} - self.assertTrue(set(address.keys()).issubset(allowed_address_fields)) - # Verify internal/sensitive fields are NOT present - self.assertNotIn("owner", address) - self.assertNotIn("creation", address) - self.assertNotIn("modified", address) - self.assertNotIn("doctype", address) - - def test_get_party_contact_and_address_handles_missing_party(self): - """_get_party_contact_and_address returns empty dicts for missing party.""" - pr = frappe.new_doc("Payment Request") - pr.party_type = None - pr.party = None - - contact, address = pr._get_party_contact_and_address() - - self.assertEqual(contact, {}) - self.assertEqual(address, {}) - - def test_get_party_contact_and_address_handles_deleted_party(self): - """_get_party_contact_and_address handles deleted party gracefully.""" - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = "NonExistentCustomer12345" - - contact, address = pr._get_party_contact_and_address() - - self.assertEqual(contact, {}) - self.assertEqual(address, {}) - - def test_v2_gateway_uses_process_v2_gateway(self): - """v2 gateways should use _process_v2_gateway flow, not legacy flow.""" - so = make_sales_order(currency="INR") - - with patch( - "erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway", - return_value=True, - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest._process_v2_gateway" - ) as mock_process_v2: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.set_payment_request_url" - ) as mock_set_url: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email" - ): - make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=True, - return_doc=True, - ) - - # v2 flow should be called - mock_process_v2.assert_called_once() - # Legacy flow should NOT be called - mock_set_url.assert_not_called() - - def test_v1_gateway_uses_legacy_flow(self): - """v1 gateways should use set_payment_request_url flow, not v2 flow.""" - so = make_sales_order(currency="INR") - - with patch( - "erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway", - return_value=False, - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.set_payment_request_url" - ) as mock_set_url: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest._process_v2_gateway" - ) as mock_process_v2: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.get_payment_url", - return_value=PAYMENT_URL, - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request._get_payment_gateway_controller" - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email" - ): - make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=True, - return_doc=True, - ) - - # Legacy flow should be called - mock_set_url.assert_called_once() - # v2 flow should NOT be called - mock_process_v2.assert_not_called() - - # ============================================================================ - # Codecov Gap Tests - # ============================================================================ - - def test_is_v2_gateway_returns_false_when_payments_not_installed(self): - """_is_v2_gateway returns False when payments app raises ValidationError.""" - from erpnext.accounts.doctype.payment_request.payment_request import _is_v2_gateway - - # Simulate payments app not installed (ValidationError from import guard) - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.payment_app_import_guard", - side_effect=frappe.ValidationError("Payments app not installed"), - ): - result = _is_v2_gateway("_Test Gateway") - self.assertFalse(result) - - def test_process_v2_gateway_handles_initiate_failure(self): - """_process_v2_gateway shows user-friendly error on initiate failure.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - # Ensure payment_gateway is set (make_payment_request may not populate it) - pr.payment_gateway = "_Test Gateway" - - # Mock PaymentController to raise an exception - mock_controller = MagicMock() - mock_controller.initiate = MagicMock(side_effect=Exception("Gateway connection failed")) - - with patch("erpnext.accounts.doctype.payment_request.payment_request.payment_app_import_guard"): - with patch.dict( - sys.modules, - { - "payments": MagicMock(), - "payments.controllers": MagicMock(PaymentController=mock_controller), - }, - ): - with self.assertRaises(frappe.ValidationError) as context: - pr._process_v2_gateway() - - self.assertIn("Failed to initiate payment", str(context.exception)) - self.assertIn("_Test Gateway", str(context.exception)) - - def test_process_v2_gateway_handles_none_psl(self): - """_process_v2_gateway throws when PaymentController returns None PSL.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - # Mock PaymentController.initiate to return (controller, None) - mock_controller_class = MagicMock() - mock_controller_class.initiate = MagicMock(return_value=(MagicMock(), None)) - - with patch("erpnext.accounts.doctype.payment_request.payment_request.payment_app_import_guard"): - with patch.dict( - sys.modules, - { - "payments": MagicMock(), - "payments.controllers": MagicMock(PaymentController=mock_controller_class), - }, - ): - with self.assertRaises(frappe.ValidationError) as context: - pr._process_v2_gateway() - - self.assertIn("failed to create a payment session", str(context.exception)) - - def test_process_v2_gateway_sets_payment_session_log(self): - """_process_v2_gateway sets payment_session_log field when it exists.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - # Add payment_session_log attribute to simulate custom field - pr.payment_session_log = None - - mock_controller_class = MagicMock() - mock_controller_class.initiate = MagicMock(return_value=(MagicMock(), "PSL-00001")) - mock_controller_class.get_payment_url = MagicMock(return_value="https://pay.example.com/xyz") - - with patch("erpnext.accounts.doctype.payment_request.payment_request.payment_app_import_guard"): - with patch.dict( - sys.modules, - { - "payments": MagicMock(), - "payments.controllers": MagicMock(PaymentController=mock_controller_class), - }, - ): - pr._process_v2_gateway() - - self.assertEqual(pr.payment_session_log, "PSL-00001") - self.assertEqual(pr.payment_url, "https://pay.example.com/xyz") - - def test_get_party_contact_and_address_supplier(self): - """_get_party_contact_and_address works for Supplier party type.""" - # Get or create test supplier - supplier_name = "_Test Supplier" - if not frappe.db.exists("Supplier", supplier_name): - frappe.get_doc( - { - "doctype": "Supplier", - "supplier_name": supplier_name, - "supplier_group": "All Supplier Groups", - } - ).insert(ignore_permissions=True) - - supplier = frappe.get_doc("Supplier", supplier_name) - original_contact = supplier.supplier_primary_contact - original_address = supplier.supplier_primary_address - - # Create test contact for supplier (always recreate to ensure known state) - contact_name = "_Test PR Supplier Contact" - if frappe.db.exists("Contact", contact_name): - frappe.delete_doc("Contact", contact_name, force=True) - - test_contact = frappe.get_doc( - { - "doctype": "Contact", - "first_name": "SupplierTest", - "last_name": "Contact", - # Email and phone are set via child tables in Frappe - "email_ids": [{"email_id": "supplier.test@example.com", "is_primary": 1}], - "phone_nos": [{"phone": "+9876543210", "is_primary_phone": 1}], - } - ) - test_contact.insert(ignore_permissions=True) - - # Create test address for supplier (always recreate to ensure known state) - address_title = "_Test PR Supplier Address" - existing_address = frappe.db.get_value("Address", {"address_title": address_title}, "name") - if existing_address: - frappe.delete_doc("Address", existing_address, force=True) - - test_address = frappe.get_doc( - { - "doctype": "Address", - "address_title": address_title, - "address_type": "Billing", - "address_line1": "456 Supplier Street", - "city": "Supplier City", - "country": "United States", - } - ) - test_address.insert(ignore_permissions=True) - - try: - # Link contact and address to supplier - supplier.db_set("supplier_primary_contact", test_contact.name, update_modified=False) - supplier.db_set("supplier_primary_address", test_address.name, update_modified=False) - - pr = frappe.new_doc("Payment Request") - pr.party_type = "Supplier" - pr.party = supplier.name - - contact, address = pr._get_party_contact_and_address() - - self.assertTrue(contact, "Contact should be returned for Supplier") - self.assertEqual(contact.get("first_name"), "SupplierTest") - self.assertEqual(contact.get("email_id"), "supplier.test@example.com") - - self.assertTrue(address, "Address should be returned for Supplier") - self.assertEqual(address.get("address_line1"), "456 Supplier Street") - self.assertEqual(address.get("city"), "Supplier City") - finally: - # Restore original values - supplier.db_set("supplier_primary_contact", original_contact, update_modified=False) - supplier.db_set("supplier_primary_address", original_address, update_modified=False) - frappe.delete_doc("Contact", test_contact.name, force=True) - frappe.delete_doc("Address", test_address.name, force=True) - - def test_get_party_contact_and_address_unsupported_party_type(self): - """_get_party_contact_and_address returns empty dicts for unsupported party types.""" - pr = frappe.new_doc("Payment Request") - pr.party_type = "Lead" # Not in field_map (only Customer/Supplier supported) - pr.party = "some-lead-name" - - # Mock the party lookup to avoid DoesNotExistError for Lead - with patch.object(frappe, "get_doc", return_value=MagicMock()): - contact, address = pr._get_party_contact_and_address() - - self.assertEqual(contact, {}) - self.assertEqual(address, {}) - - def test_get_party_contact_and_address_deleted_contact(self): - """_get_party_contact_and_address handles deleted contact gracefully.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - # Set a nonexistent contact name - original_contact = customer.customer_primary_contact - customer.db_set("customer_primary_contact", "NonExistentContact12345", update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - contact, _address = pr._get_party_contact_and_address() - - # Should return empty dict for contact, not raise - self.assertEqual(contact, {}) - finally: - # Restore original contact - customer.db_set("customer_primary_contact", original_contact, update_modified=False) - - def test_get_party_contact_and_address_deleted_address(self): - """_get_party_contact_and_address handles deleted address gracefully.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - # Set a nonexistent address name - original_address = customer.customer_primary_address - customer.db_set("customer_primary_address", "NonExistentAddress12345", update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - _contact, address = pr._get_party_contact_and_address() - - # Should return empty dict for address, not raise - self.assertEqual(address, {}) - finally: - # Restore original address - customer.db_set("customer_primary_address", original_address, update_modified=False) - - # ============================================================================ - # before_submit Flow Gap Tests - # ============================================================================ - - def test_v2_gateway_sends_email_when_not_muted(self): - """v2 gateways should send email when mute_email is False.""" - so = make_sales_order(currency="INR") - - with patch( - "erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway", - return_value=True, - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest._process_v2_gateway" - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email" - ) as mock_send_email: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.make_communication_entry" - ) as mock_make_comm: - make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=False, # Email should be sent - submit_doc=True, - return_doc=True, - ) - - mock_send_email.assert_called_once() - mock_make_comm.assert_called_once() - - def test_v1_phone_payment_skips_email(self): - """Phone payment channel should skip email sending entirely.""" - so = make_sales_order(currency="INR") - - with patch( - "erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway", - return_value=False, - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.request_phone_payment" - ) as mock_phone: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email" - ) as mock_send_email: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.make_communication_entry" - ) as mock_make_comm: - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=False, - submit_doc=False, - return_doc=True, - ) - pr.payment_channel = "Phone" - pr.submit() - - # Phone payment should be called - mock_phone.assert_called_once() - # Email should NOT be sent for phone payments - mock_send_email.assert_not_called() - mock_make_comm.assert_not_called() - - def test_no_payment_gateway_skips_payment_processing(self): - """Payment request without gateway should skip all payment processing.""" - so = make_sales_order(currency="INR") - - with patch("erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway") as mock_is_v2: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest._process_v2_gateway" - ) as mock_v2: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.set_payment_request_url" - ) as mock_v1: - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - # Clear the payment gateway - pr.payment_gateway = None - pr.payment_gateway_account = None - pr.submit() - - # Neither v1 nor v2 flow should be called - mock_is_v2.assert_not_called() - mock_v2.assert_not_called() - mock_v1.assert_not_called() - - def test_outward_payment_request_skips_gateway_processing(self): - """Outward payment requests should not trigger v1/v2 gateway flows.""" - po = create_purchase_order() - - with patch("erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway") as mock_is_v2: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest._process_v2_gateway" - ) as mock_v2: - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.set_payment_request_url" - ) as mock_v1: - pr = make_payment_request( - dt="Purchase Order", - dn=po.name, - recipient_id="test@example.com", - mute_email=True, - submit_doc=True, - return_doc=True, - ) - - # Outward requests should not call gateway processing - mock_is_v2.assert_not_called() - mock_v2.assert_not_called() - mock_v1.assert_not_called() - self.assertEqual(pr.payment_request_type, "Outward") - self.assertEqual(pr.status, "Initiated") - - def test_flags_mute_email_suppresses_communication(self): - """flags.mute_email should suppress email even when mute_email field is False.""" - so = make_sales_order(currency="INR") - - with patch( - "erpnext.accounts.doctype.payment_request.payment_request._is_v2_gateway", - return_value=True, - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest._process_v2_gateway" - ): - with patch( - "erpnext.accounts.doctype.payment_request.payment_request.PaymentRequest.send_email" - ) as mock_send_email: - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=False, # Field says don't mute - submit_doc=False, - return_doc=True, - ) - pr.flags.mute_email = True # But flag says mute - pr.submit() - - # Email should NOT be sent because of flags.mute_email - mock_send_email.assert_not_called() - - # ============================================================================ - # _get_party_contact_and_address Edge Case Tests - # ============================================================================ - - def test_contact_uses_mobile_no_fallback(self): - """Contact with mobile_no but no phone should use mobile_no as fallback.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - # Create contact with only mobile_no (via phone_nos child table) - contact_name = "_Test Contact Mobile Only" - if frappe.db.exists("Contact", contact_name): - frappe.delete_doc("Contact", contact_name, force=True) - - test_contact = frappe.get_doc( - { - "doctype": "Contact", - "first_name": "Mobile", - "last_name": "Only", - "email_id": "mobile.only@example.com", - # Phone numbers in Frappe are set via the phone_nos child table - # is_primary_mobile_no sets the mobile_no field, is_primary_phone sets phone - "phone_nos": [{"phone": "+1999888777", "is_primary_mobile_no": 1}], - } - ) - test_contact.insert(ignore_permissions=True) - - original_contact = customer.customer_primary_contact - customer.db_set("customer_primary_contact", test_contact.name, update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - contact, _ = pr._get_party_contact_and_address() - - # Should fall back to mobile_no since phone is empty - self.assertEqual(contact.get("phone"), "+1999888777") - finally: - customer.db_set("customer_primary_contact", original_contact, update_modified=False) - frappe.delete_doc("Contact", test_contact.name, force=True) - - def test_contact_with_no_email_returns_empty_string(self): - """Contact without email_id should return empty string, not None.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - # Create contact without email - contact_name = "_Test Contact No Email" - if frappe.db.exists("Contact", contact_name): - frappe.delete_doc("Contact", contact_name, force=True) - - test_contact = frappe.get_doc( - { - "doctype": "Contact", - "first_name": "No", - "last_name": "Email", - "email_id": "", # No email - "phone": "+1234567890", - } - ) - test_contact.insert(ignore_permissions=True) - - original_contact = customer.customer_primary_contact - customer.db_set("customer_primary_contact", test_contact.name, update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - contact, _ = pr._get_party_contact_and_address() - - # Should be empty string, not None - self.assertEqual(contact.get("email_id"), "") - self.assertEqual(contact.get("email"), "") - self.assertIsNotNone(contact.get("email_id")) - finally: - customer.db_set("customer_primary_contact", original_contact, update_modified=False) - frappe.delete_doc("Contact", test_contact.name, force=True) - - def test_address_with_missing_optional_fields(self): - """Address missing optional fields should return empty strings.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - # Create minimal address - address_title = "_Test Minimal Address" - existing = frappe.db.get_value("Address", {"address_title": address_title}, "name") - if existing: - frappe.delete_doc("Address", existing, force=True) - - test_address = frappe.get_doc( - { - "doctype": "Address", - "address_title": address_title, - "address_type": "Billing", - "address_line1": "123 Main St", - # No address_line2, state, pincode - "city": "Test City", - "country": "United States", - } - ) - test_address.insert(ignore_permissions=True) - - original_address = customer.customer_primary_address - customer.db_set("customer_primary_address", test_address.name, update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - _, address = pr._get_party_contact_and_address() - - # Optional fields should be empty strings, not None - self.assertEqual(address.get("address_line2"), "") - self.assertEqual(address.get("state"), "") - self.assertEqual(address.get("pincode"), "") - # Required fields should have values - self.assertEqual(address.get("address_line1"), "123 Main St") - self.assertEqual(address.get("city"), "Test City") - finally: - customer.db_set("customer_primary_address", original_address, update_modified=False) - frappe.delete_doc("Address", test_address.name, force=True) - - def test_customer_without_primary_contact(self): - """Customer without primary_contact set should return empty contact dict.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - original_contact = customer.customer_primary_contact - customer.db_set("customer_primary_contact", None, update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - contact, _ = pr._get_party_contact_and_address() - - self.assertEqual(contact, {}) - finally: - customer.db_set("customer_primary_contact", original_contact, update_modified=False) - - def test_customer_without_primary_address(self): - """Customer without primary_address set should return empty address dict.""" - customer = frappe.get_doc("Customer", "_Test Customer") - - original_address = customer.customer_primary_address - customer.db_set("customer_primary_address", None, update_modified=False) - - try: - pr = frappe.new_doc("Payment Request") - pr.party_type = "Customer" - pr.party = customer.name - - _, address = pr._get_party_contact_and_address() - - self.assertEqual(address, {}) - finally: - customer.db_set("customer_primary_address", original_address, update_modified=False) - - # ============================================================================ - # get_tx_data Edge Case Tests - # ============================================================================ - - def test_get_tx_data_multi_currency(self): - """get_tx_data handles multi-currency payment requests correctly.""" - # Create USD sales order - so = make_sales_order(currency="USD", qty=1, rate=100) - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - USD - _TC", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - tx_data = pr.get_tx_data() - - self.assertEqual(tx_data.currency, "USD") - self.assertGreater(tx_data.amount, 0) - - def test_get_tx_data_without_party(self): - """get_tx_data returns empty contact/address when party is not set.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - # Clear party info - pr.party_type = None - pr.party = None - - tx_data = pr.get_tx_data() - - self.assertEqual(tx_data.payer_contact, {}) - self.assertEqual(tx_data.payer_address, {}) - - def test_get_tx_data_loyalty_and_discount_are_none(self): - """get_tx_data sets loyalty_points and discount_amount to None.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - tx_data = pr.get_tx_data() - - # These are explicitly set to None as per current implementation - self.assertIsNone(tx_data.loyalty_points) - self.assertIsNone(tx_data.discount_amount) - - # ============================================================================ - # _process_v2_gateway Success Path Tests - # ============================================================================ - - def test_process_v2_gateway_sets_payment_url(self): - """_process_v2_gateway sets payment_url from PaymentController.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - - expected_url = "https://gateway.example.com/pay/session123" - mock_controller_class = MagicMock() - mock_controller_class.initiate = MagicMock(return_value=(MagicMock(), "PSL-00001")) - mock_controller_class.get_payment_url = MagicMock(return_value=expected_url) - - with patch("erpnext.accounts.doctype.payment_request.payment_request.payment_app_import_guard"): - with patch.dict( - sys.modules, - { - "payments": MagicMock(), - "payments.controllers": MagicMock(PaymentController=mock_controller_class), - }, - ): - pr._process_v2_gateway() - - self.assertEqual(pr.payment_url, expected_url) - mock_controller_class.get_payment_url.assert_called_once_with("PSL-00001") - - def test_process_v2_gateway_logs_error_on_failure(self): - """_process_v2_gateway logs error with frappe.log_error on initiate failure.""" - so = make_sales_order(currency="INR") - - pr = make_payment_request( - dt="Sales Order", - dn=so.name, - recipient_id="test@example.com", - payment_gateway_account="_Test Gateway - INR - _TC", - mute_email=True, - submit_doc=False, - return_doc=True, - ) - # Ensure payment_gateway is set (make_payment_request may not populate it) - pr.payment_gateway = "_Test Gateway" - - mock_controller_class = MagicMock() - mock_controller_class.initiate = MagicMock(side_effect=Exception("API timeout")) - - with patch("erpnext.accounts.doctype.payment_request.payment_request.payment_app_import_guard"): - with patch.dict( - sys.modules, - { - "payments": MagicMock(), - "payments.controllers": MagicMock(PaymentController=mock_controller_class), - }, - ): - with patch("frappe.log_error") as mock_log_error: - with self.assertRaises(frappe.ValidationError): - pr._process_v2_gateway() - - # Verify error was logged - mock_log_error.assert_called_once() - call_kwargs = mock_log_error.call_args - self.assertIn("Payment Initialization Failed", str(call_kwargs)) - self.assertIn("_Test Gateway", str(call_kwargs)) - - def test_payment_request_with_subscription(self): - make_plans() - - subscription_plan = frappe.get_doc("Subscription Plan", "_Test Plan Name") - subscription_plan.payment_gateway = "_Test Gateway - INR - _TC" - subscription_plan.save() - - subscription = create_subscription( - plans=[{"plan": "_Test Plan Name", "qty": 1}], - start_date=nowdate(), - generate_invoice_at="Prepaid (bill at period start)", - submit_invoice=1, - ) - invoice_name = frappe.get_value( - "Sales Invoice", - { - "subscription": subscription.name, - "docstatus": 1, - "is_return": 0, - }, - "name", - order_by="from_date asc", - ) - - payment_request = make_payment_request( - dt="Sales Invoice", - dn=invoice_name, - recipient_id="test@example.com", - ) - - self.assertEqual(payment_request.is_a_subscription, 1) - self.assertEqual(len(payment_request.subscription_plans), 1) - - subscription_plan = payment_request.subscription_plans[0] - self.assertEqual(subscription_plan.plan, "_Test Plan Name") - self.assertEqual(subscription_plan.qty, 1) - self.assertEqual(payment_request.reference_doctype, "Sales Invoice") - self.assertEqual(payment_request.reference_name, invoice_name) - - def test_payment_request_without_subscription(self): - si = create_sales_invoice() - payment_request = make_payment_request( - dt="Sales Invoice", - dn=si.name, - recipient_id="test@example.com", - ) - self.assertEqual(payment_request.is_a_subscription, 0) - self.assertEqual(len(payment_request.subscription_plans), 0) - self.assertEqual(payment_request.reference_doctype, "Sales Invoice") - self.assertEqual(payment_request.reference_name, si.name) ->>>>>>> 1b81db4 (fix/payment-request-subscription-plans-population (#57494))