From db815c2c6ba922efbab2410461eb63321b246ec7 Mon Sep 17 00:00:00 2001 From: David Arnold Date: Sun, 5 Nov 2023 14:51:10 +0100 Subject: [PATCH] feat: implement generic payment controller --- .../controllers/payment_gateway_controller.py | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 payments/controllers/payment_gateway_controller.py diff --git a/payments/controllers/payment_gateway_controller.py b/payments/controllers/payment_gateway_controller.py new file mode 100644 index 00000000..580d0011 --- /dev/null +++ b/payments/controllers/payment_gateway_controller.py @@ -0,0 +1,480 @@ +import json + +from urllib.parse import urlencode + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.integrations.utils import create_request_log + + +class PaymentGatewayController(Document): + """This controller implemets the public API of payment gateway controllers.""" + + def on_refdoc_submission(self, tx_data): + """Invoked by the reference document for example in order to validate the transaction data. + + Should throw on error with an informative user facing message. + + Parameters: + tx_data (dict): The transaction data for which to invoke this method + + Returns: None + """ + raise NotImplementedError + + def initiate_payment(self, tx_data, name=None): + """Standardized entrypoint to initiate a payment + + Parameters: + tx_data (dict): The transaction data for which to invoke this method + name (str, optional): name of the integration request when called via super, + e.g. in order to name it after a prefetched remote token + + Returns: Integration Request + """ + + self.integration_request = create_request_log(tx_data, self.name, name) + return self.integration_request + + def is_user_flow_initiation_delegated(self, integration_request_name): + """Invoked by the reference document which initiates a payment integration request. + + Some old or exotic (think, for example: incasso/facturing) gateways may initate the user flow on their own terms. + + Parameters: + integration_request_name (str): The unique integration request reference, however implementors may disregard and + choose to keep this particular state, if any, on the in-memory controller object + + Returns: + bool: Wether to instruct the reference document to initiate any communication or not regarding the payment. + """ + return False + + def _should_have_mandate(self): + """Invoked by `procede` in order to deterine if the mandated flow branch ought to be elected + + Has access to self.integration_request with _updated_ transaction data. + + Returns: bool + """ + assert self.integration_request + assert self.tx_data + return False + + def _has_mandate(self): + """Invoked by `procede` in order to deterine if, in the mandated flow branch, a mandate needs to be aquired first + + Has access to self.integration_request with _updated_ transaction data. + + Typically queries a custom mandate doctype that is specific to a particular payment gateway controller. + + Returns: bool + """ + assert self.integration_request + assert self.tx_data + return False + + def _create_mandate(self): + """Invoked by `procede` in order to create a mandate (in draft) for which a mandate aquisition is inminent + + Has access to self.integration_request with _updated_ transaction data. + + Returns: None + """ + assert self.integration_request + assert self.tx_data + return None + + def procede(self, integration_request, updated_tx_data): + """Standardized entrypoint to submit a payment request for remote processing. + + It is invoked from a customer flow and thus catches errors into a friendly, non-sensitive message. + + Parameters: + updated_tx_data (dict): Updates to the inital transaction data, can reflect customer choices and modify the flow + + Returns: + dict: Frappe Client Redirection Instructions + """ + self.integration_request = frappe.get_doc("Integration Request", integration_request) + self.integration_request.update_status(updated_tx_data, "Queued") + self.tx_data = frappe._dict(json.loads(self.integration_request.data)) # QoL + + if self._should_have_mandate() and not self._has_mandate(): + self._create_mandate() + + tx_data = frappe._dict(json.loads(self.integration_request.data)) + + # Payment Gateway Api processing + try: + success = self.process_integration_request(tx_data) + except Exception: + frappe.log_error(frappe.get_traceback()) + return { + "redirect_to": frappe.redirect_to_message( + _("Payment Gateway Error"), + _( + "There's been an issue with the server's configuration for {0}." + "Don't worry, in case of failure amount will get refunded to your account." + ).format(self.name), + ), + "status": 401, + } + + # Reference Document Processing, if successful + if success: + try: + ref_doc = frappe.get_doc( + self.integration_request.reference_doctype, self.integration_request.reference_docname + ) + if ref_doc.hasattr("on_payment_authorized"): + ref_doc.run_method("on_payment_authorized", self.flags.status_changed_to) + except Exception: + error = frappe.log_error(frappe.get_traceback()) + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _( + "There's been an issue with our server's processing of your otherwise successful payment." + "Please contact customer support and mention reference {0} to resolve your payment." + ).format(error), + ), + "status": 500, + } + + return self.get_redirect_url_after_process_integration_request() + + def _initiate_mandate_acquisition(self): + """Invoked by procede to initiate a mandate acquisiton flow. + + It is stateful and can read state from self.integration_request and self.tx_data and write to self.integration_request. + + Returns: bool + """ + assert ( + self.integration_request and self.tx_data + ), "Do not invoke _initiate_mandate_acquisition directly. It should be invoked by procede" + raise NotImplementedError + + def _initiate_mandated_charge(self): + """Invoked by procede or after having aquired a mandate in order to initiate a mandated charge flow. + + It is stateful and can read state from self.integration_request and self.tx_data and write to self.integration_request. + + Returns: bool + """ + assert ( + self.integration_request and self.tx_data + ), "Do not invoke _initiate_mandated_charge directly. It should be invoked by procede" + raise NotImplementedError + + def _initiate_charge(self): + """Invoked by procede in order to initiate a charge flow. + + It is stateful and can read state from self.integration_request and self.tx_data and write to self.integration_request. + + Returns: bool + """ + assert ( + self.integration_request and self.tx_data + ), "Do not invoke _initiate_charge directly. It should be invoked by procede" + raise NotImplementedError + + def load_integration_request(self, integration_request_name): + self.integration_request = frappe.get_doc("Integration Request", integration_request_name) + self.tx_data = frappe._dict(json.loads(self.integration_request.data)) # QoL + + def validate_response_payload(self, server_to_server): + """Invoked by process_* functions. + + It is stateful and can read state from self.integration_request and self.tx_data and has access to self.response_payload. + + Return: None or Frappe Redirection Dict + """ + assert ( + self.integration_request and self.tx_data and self.response_payload + ), "Don't invoke controller.validate_payload directly. It is invoked by process_* functions." + try: + self._validate_response_payload(self) + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _("There's been an issue with your payment."), + ), + "status": 500, + } + + def _validate_response_payload(self): + raise NotImplementedError + + def process_mandate_acquisition_response(self, payload, server_to_server=False): + """Invoked by the mandate acquisition flow coming from either the client (e.g. from ./checkout) or the gateway server (IPN). + + It is stateful and can read state from self.integration_request and self.tx_data and write to self.integration_request. + + Implementations have access to the response payload via self.response_payload as well as to the reference document via self.ref_doc. + + Returns: (None or dict) Indicating the customer facing message and next action (redirect) + """ + assert ( + self.integration_request and self.tx_data + ), "Invoke controller.load_integration_request before invoking process_mandate_acquisition_response" + assert self.success_flags, "the controller must declare its `success_flags` as an iterable" + + self.response_payload = payload + self.validate_response_payload(server_to_server) + + def _error_value(error): + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _( + "Our server had an issue processing your mandate acquisition. Please contact customer support mentioning: {0}" + ).format(error), + ), + "status": 500, + } + + return_value = None + + try: + return_value = self._process_mandate_acquisition_response() + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return _error_value(error) + + assert ( + self.flags.status_changed_to + ), "_process_mandate_acquisition_response must set self.flags.status_changed_to" + + ref_doc = frappe.get_doc( + self.integration_request.reference_doctype, self.integration_request.reference_docname + ) + ref_doc_return_value = None + + try: + if ref_doc.hasattr("on_payment_mandate_acquisition_processed"): + ref_doc_return_value = ref_doc.run_method( + "on_payment_mandate_acquisition_processed", self.flags.status_changed_to + ) + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return _error_value(error) + + if self.flags.status_changed_to in self.success_flags: + self.integration_request.handle_success(self.response_payload) + if server_to_server: + return + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment mandate successfully acquired"), + "action": {"redirect_to": "/"}, + } + ) + else: + assert ( + self.pre_authorized_flags + ), "the controller must declare its `pre_authorized_flags` as an iterable" + if self.flags.status_changed_to in self.pre_authorized_flags: + raise NotImplementedError + if server_to_server: + return + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment mandate successfully authorized"), + "action": {"redirect_to": "/"}, + } + ) + else: + self.integration_request.handle_failure(self.response_payload) + if server_to_server: + return + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment mandate acquisition failed"), + "action": {"redirect_to": "/"}, + } + ) + + def _process_mandate_acquisition_response(self): + raise NotImplementedError + + def process_mandated_charge_response(self, payload, server_to_server=True): + """Invoked by the mandated charge flow coming from either the client (e.g. from ./checkout) or the gateway server (IPN). + + Note: server_to_server is True by default on mandated charges (the typical flow is server-to-server only). + + It is stateful and can read state from self.integration_request and self.tx_data and write to self.integration_request. + + Implementations have access to the response payload via self.response_payload as well as to the reference document via self.ref_doc. + + Returns: (None or dict) Indicating the customer facing message and next action (redirect) + """ + assert ( + self.integration_request and self.tx_data + ), "Invoke controller.load_integration_request before invoking process_mandated_charge_response" + assert self.success_flags, "the controller must declare its `success_flags` as an iterable" + + self.response_payload = payload + self.validate_response_payload() + + def _error_value(error): + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _( + "Our server had an issue processing your mandated charge. Please contact customer support mentioning: {0}" + ).format(error), + ), + "status": 500, + } + + return_value = None + + try: + return_value = self._process_mandated_charge_response() + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return _error_value(error) + + assert ( + self.flags.status_changed_to + ), "_process_mandated_charge_response must set self.flags.status_changed_to" + + ref_doc = frappe.get_doc( + self.integration_request.reference_doctype, self.integration_request.reference_docname + ) + ref_doc_return_value = None + + try: + if ref_doc.hasattr("on_payment_mandated_charge_processed"): + ref_doc_return_value = ref_doc.run_method( + "on_payment_mandated_charge_processed", self.flags.status_changed_to + ) + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return _error_value(error) + + if self.flags.status_changed_to in self.success_flags: + self.integration_request.handle_success(self.response_payload) + if server_to_server: + return + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment mandate charge succeeded"), + "action": {"redirect_to": "/"}, + } + ) + else: + self.integration_request.handle_failure(self.response_payload) + if server_to_server: + return + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment mandate charge failed"), + "action": {"redirect_to": "/"}, + } + ) + + def _process_mandated_charge_response(self, payload): + raise NotImplementedError + + def process_charge_response(self, payload, server_to_server=False): + """Invoked by the charge flow coming from either the client (e.g. from ./checkout) or the gateway server (IPN). + + It is stateful and can read state from self.integration_request and self.tx_data and write to self.integration_request. + + Implementations have access to the response payload via self.response_payload as well as to the reference document via self.ref_doc. + + Returns: (None or dict) Indicating the customer facing message and next action (redirect) + """ + assert ( + self.integration_request and self.tx_data + ), "Invoke controller.load_integration_request before invoking process_charge_response" + assert self.success_flags, "the controller must declare its `success_flags` as an iterable" + + self.response_payload = payload + self.validate_response_payload() + + def _error_value(error): + return { + "redirect_to": frappe.redirect_to_message( + _("Server Error"), + _( + "Our server had an issue processing your charge. Please contact customer support mentioning: {0}" + ).format(error), + ), + "status": 500, + } + + return_value = None + + try: + return_value = self._process_charge_response() + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return _error_value(error) + + assert ( + self.flags.status_changed_to + ), "_process_charge_response must set self.flags.status_changed_to" + + ref_doc = frappe.get_doc( + self.integration_request.reference_doctype, self.integration_request.reference_docname + ) + ref_doc_return_value = None + + try: + if ref_doc.hasattr("on_payment_charge_processed"): + ref_doc_return_value = ref_doc.run_method( + "on_payment_charge_processed", self.flags.status_changed_to + ) + except Exception: + error = frappe.log_error(frappe.get_traceback()) + if not server_to_server: + return _error_value(error) + + if self.flags.status_changed_to in self.success_flags: + self.integration_request.handle_success(self.response_payload) + if server_to_server: + return + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment charge succeeded"), + "action": {"redirect_to": "/"}, + } + ) + else: + self.integration_request.handle_failure(self.response_payload) + return ( + ref_doc_return_value + or return_value + or { + "message": _("Payment charge failed"), + "action": {"redirect_to": "/"}, + } + ) + + def _process_charge_response(self, payload): + raise NotImplementedError