From 4f8cc1359b77749f42bd8fad0ad9612461a47996 Mon Sep 17 00:00:00 2001 From: foppe Date: Fri, 20 Feb 2026 12:52:43 +0100 Subject: [PATCH] feat(payments): add PaymentController v2 gateway support Add support for the new PaymentController interface from frappe/payments, enabling Payment Request to work with v2 gateways while maintaining backward compatibility with v1. Related: frappe/payments#192 --- .../payment_request/payment_request.py | 177 +++++++++++++++++- 1 file changed, 169 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index c870f0b55d8..a74f3808142 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -36,6 +36,27 @@ def _get_payment_gateway_controller(*args, **kwargs): return get_payment_gateway_controller(*args, **kwargs) +def _is_v2_gateway(payment_gateway): + """Check if a payment gateway implements the new PaymentController interface. + + Delegates to payments.utils.is_v2_gateway() which centralizes the v2 detection logic. + Returns False if payments app is not installed, doesn't have v2 support, or if + any error occurs during detection (to prevent submission failures). + """ + try: + with payment_app_import_guard(): + from payments.utils import is_v2_gateway + return is_v2_gateway(payment_gateway) + except frappe.ValidationError: + # payments app not installed - fall back to v1 + return False + except Exception as e: + # Catch-all for any other errors (database errors, misconfigured gateways, etc.) + # to prevent submission failures - fall back to v1 flow + frappe.logger().warning(f"Error detecting v2 gateway for '{payment_gateway}': {e}", exc_info=True) + return False + + class PaymentRequest(Document): # begin: auto-generated types # This code is auto-generated. Do not modify anything in this block. @@ -227,18 +248,160 @@ class PaymentRequest(Document): elif self.payment_request_type == "Inward": self.status = "Requested" - if self.payment_request_type == "Inward": - if self.payment_channel == "Phone": + if self.payment_request_type == "Inward" and self.payment_gateway: + if _is_v2_gateway(self.payment_gateway): + # New PaymentController flow (v2 gateways) + self._process_v2_gateway() + elif self.payment_channel == "Phone": + # Legacy v1 phone payment - phone payments do not generate email/link + # communications as the payment is initiated directly via phone channel self.request_phone_payment() + return else: + # Legacy v1 URL payment self.set_payment_request_url() - if not (self.mute_email or self.flags.mute_email): - self.send_email() - self.make_communication_entry() + + if not (self.mute_email or self.flags.mute_email): + self.send_email() + self.make_communication_entry() def on_submit(self): self.update_reference_advance_payment_status() + def _process_v2_gateway(self): + """Process payment using the new PaymentController interface (v2 gateways).""" + tx_data = self.get_tx_data() + with payment_app_import_guard(): + from payments.controllers import PaymentController + + try: + _controller, psl_name = PaymentController.initiate(tx_data, self.payment_gateway) + except Exception as e: + # Log full exception for debugging, show generic message to user + frappe.log_error( + title=_("Payment Initialization Failed"), + message=f"Gateway: {self.payment_gateway}, Error: {e}\n{frappe.get_traceback()}", + ) + frappe.throw( + _("Failed to initiate payment with {0}. Please try again or contact support.").format( + self.payment_gateway + ), + title=_("Payment Initialization Failed"), + ) + if not psl_name: + frappe.throw( + _("Payment gateway {0} failed to create a payment session").format(self.payment_gateway), + title=_("Payment Initialization Failed"), + ) + self.payment_url = PaymentController.get_payment_url(psl_name) + # Store PSL reference for debugging and reconciliation + # (payment_session_log field added by payments app as custom field) + if hasattr(self, "payment_session_log"): + self.payment_session_log = psl_name + + def get_tx_data(self): + """Prepare standardized transaction data for PaymentController. + + This method creates the tx_data dict expected by PaymentController.initiate(). + Must match the TxData dataclass fields from payments.types. + + Note on reference fields: + reference_doctype/reference_docname point to this Payment Request (the wrapper), + not the underlying business document (Sales Invoice, etc.). This is intentional + because Payment Request handles callbacks, reconciliation, and status updates. + The business document reference is available via self.reference_doctype/reference_name. + """ + payer_contact, payer_address = self._get_party_contact_and_address() + + return frappe._dict( + { + "amount": self.get_request_amount(), + "currency": self.currency, + "reference_doctype": self.doctype, + "reference_docname": self.name, + "payer_contact": payer_contact, + "payer_address": payer_address, + "loyalty_points": None, + "discount_amount": None, + } + ) + + def _get_party_contact_and_address(self): + """Get primary contact and address for the party, with only payment-relevant fields. + + Returns minimal data needed for payment processing to avoid exposing + unnecessary PII to the payment gateway layer. + """ + if not (self.party_type and self.party): + return {}, {} + + # Map party type to field names for primary contact/address + field_map = { + "Customer": ("customer_primary_contact", "customer_primary_address"), + "Supplier": ("supplier_primary_contact", "supplier_primary_address"), + } + if self.party_type not in field_map: + return {}, {} + + contact_field, address_field = field_map[self.party_type] + + # Fetch only the primary contact/address names from party (single query) + party_data = frappe.get_value( + self.party_type, self.party, [contact_field, address_field], as_dict=True + ) + if not party_data: + return {}, {} + + payer_contact = self._get_contact_fields(party_data.get(contact_field)) + payer_address = self._get_address_fields(party_data.get(address_field)) + + return payer_contact, payer_address + + def _get_contact_fields(self, contact_name): + """Extract payment-relevant fields from a Contact.""" + if not contact_name: + return {} + + contact = frappe.get_value( + "Contact", + contact_name, + ["first_name", "last_name", "email_id", "phone", "mobile_no"], + as_dict=True, + ) + if not contact: + return {} + + return { + "first_name": contact.first_name or "", + "last_name": contact.last_name or "", + "email_id": contact.email_id or "", + "email": contact.email_id or "", # Alias for gateway compatibility + "phone": contact.phone or contact.mobile_no or "", + } + + def _get_address_fields(self, address_name): + """Extract payment-relevant fields from an Address.""" + if not address_name: + return {} + + address = frappe.get_value( + "Address", + address_name, + ["address_line1", "address_line2", "city", "state", "pincode", "country"], + as_dict=True, + ) + if not address: + return {} + + return { + "address_line1": address.address_line1 or "", + "address_line2": address.address_line2 or "", + "city": address.city or "", + "state": address.state or "", + "pincode": address.pincode or "", + "country": address.country or "", + } + def request_phone_payment(self): controller = _get_payment_gateway_controller(self.payment_gateway) request_amount = self.get_request_amount() @@ -384,9 +547,7 @@ class PaymentRequest(Document): "mode_of_payment": self.mode_of_payment, "reference_no": self.name, # to prevent validation error "reference_date": nowdate(), - "remarks": "Payment Entry against {} {} via Payment Request {}".format( - self.reference_doctype, self.reference_name, self.name - ), + "remarks": f"Payment Entry against {self.reference_doctype} {self.reference_name} via Payment Request {self.name}", } )