Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 169 additions & 8 deletions erpnext/accounts/doctype/payment_request/payment_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
0spinboson marked this conversation as resolved.
Comment thread
0spinboson marked this conversation as resolved.
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.
Expand Down Expand Up @@ -227,18 +248,160 @@ def before_submit(self):
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()}",
)
Comment thread
0spinboson marked this conversation as resolved.
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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()
Expand Down Expand Up @@ -384,9 +547,7 @@ def create_payment_entry(self, submit=True):
"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}",
}
)

Expand Down
Loading
Loading