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
This commit is contained in:
foppe
2026-02-20 12:52:43 +01:00
parent 0cd0b8213d
commit 4f8cc1359b

View File

@@ -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}",
}
)