mirror of
https://github.com/frappe/erpnext.git
synced 2026-05-26 00:14:50 +00:00
Merge pull request #51723 from nlvegan/feat/payment-controller-v2-support
feat(payments): Add PaymentController v2 gateway support
This commit is contained in:
@@ -36,6 +36,27 @@ def _get_payment_gateway_controller(*args, **kwargs):
|
|||||||
return 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):
|
class PaymentRequest(Document):
|
||||||
# begin: auto-generated types
|
# begin: auto-generated types
|
||||||
# This code is auto-generated. Do not modify anything in this block.
|
# 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":
|
elif self.payment_request_type == "Inward":
|
||||||
self.status = "Requested"
|
self.status = "Requested"
|
||||||
|
|
||||||
if self.payment_request_type == "Inward":
|
if self.payment_request_type == "Inward" and self.payment_gateway:
|
||||||
if self.payment_channel == "Phone":
|
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()
|
self.request_phone_payment()
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
|
# Legacy v1 URL payment
|
||||||
self.set_payment_request_url()
|
self.set_payment_request_url()
|
||||||
if not (self.mute_email or self.flags.mute_email):
|
|
||||||
self.send_email()
|
if not (self.mute_email or self.flags.mute_email):
|
||||||
self.make_communication_entry()
|
self.send_email()
|
||||||
|
self.make_communication_entry()
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
self.update_reference_advance_payment_status()
|
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):
|
def request_phone_payment(self):
|
||||||
controller = _get_payment_gateway_controller(self.payment_gateway)
|
controller = _get_payment_gateway_controller(self.payment_gateway)
|
||||||
request_amount = self.get_request_amount()
|
request_amount = self.get_request_amount()
|
||||||
@@ -384,9 +547,7 @@ class PaymentRequest(Document):
|
|||||||
"mode_of_payment": self.mode_of_payment,
|
"mode_of_payment": self.mode_of_payment,
|
||||||
"reference_no": self.name, # to prevent validation error
|
"reference_no": self.name, # to prevent validation error
|
||||||
"reference_date": nowdate(),
|
"reference_date": nowdate(),
|
||||||
"remarks": "Payment Entry against {} {} via Payment Request {}".format(
|
"remarks": f"Payment Entry against {self.reference_doctype} {self.reference_name} via Payment Request {self.name}",
|
||||||
self.reference_doctype, self.reference_name, self.name
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user