From f5ef6f52036e29358260532bcf385954ea1ca876 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 | 453 ++++++++++++++++++ 1 file changed, 453 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..fcf423d4 --- /dev/null +++ b/payments/controllers/payment_gateway_controller.py @@ -0,0 +1,453 @@ +import json +import functools + +from urllib.parse import urlencode + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.integrations.utils import create_request_log + + +def _error_value(error, flow): + return _( + "Our server had an issue processing your {0}. Please contact customer support mentioning: {1}" + ).format(flow, error) + + +def decorate_process_response(func): + @functools.wraps(func) + def wrapper(cls, integration_request_name, *, payload): + assert cls.success_flags, "the controller must declare its `success_flags` as an iterable" + cls.integration_request = frappe.get_doc("Integration Request", integration_request_name) + cls.tx_data = frappe._dict(json.loads(cls.integration_request.data)) # QoL + # guard against already processed or currently being processed payloads via another entrypoint + try: + self.integration_request.lock(timeout=3) # max processing allowance of alternative flow + except frappe.DocumentLockedError: + return json.loads(cls.integration_request.data).get("saved_return_value") + else: + cls.ref_doc = frappe.get_doc( + cls.integration_request.reference_doctype, + cls.integration_request.reference_docname, + ) + cls.response_payload = payload + cls.validate_response_payload() + + return_value = func(cls, payload) + cls.integration_request.update_status({"saved_return_value": ret}, cls.integration_request.status) + return return_value + + return wrapper + + +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 `proceed` 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 _get_mandate(self): + """Invoked by `proceed` 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: None | Mandate() + """ + assert self.integration_request + assert self.tx_data + return None + + def _create_mandate(self): + """Invoked by `proceed` 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 | Mandate() + """ + assert self.integration_request + assert self.tx_data + return None + + def proceed(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: { + type: mandate_acquisition|mandated_charge|charge + mandate: json of the mandate doc + txdata: json of the current tx data + payload: Payload according to the needs of the specific gateway flow frontend implementation + } + """ + 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 + + self.mandate = self._get_mandate() + + try: + + if self._should_have_mandate() and not self.mandate: + self.mandate = self._create_mandate() + return { + "type": "mandate_acquisition", + "mandate": frappe.as_json(self.mandate), + "txdata": frappe.as_json(self.tx_data), + "payload": self._initiate_mandate_acquisition(), + } + else self.mandate: + return { + "type": "mandated_charge", + "mandate": frappe.as_json(self.mandate), + "txdata": frappe.as_json(self.tx_data), + "payload": self._initiate_mandated_charge(), + } + else: + return self._initiate_charge() + return { + "type": "charge", + "txdata": frappe.as_json(self.tx_data), + "payload": self._initiate_charge(), + } + + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("There has been an issue with the server's configuration for {0}. Please contact customer care mentioning: {1}").format(self.name, error), + http_status_code=401, + indicator_color="yellow", + ) + + def _initiate_mandate_acquisition(self): + """Invoked by proceed 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. + + It also has access to the mandate document via self.mandate. + + Returns: {} - gateway specific frontend data + """ + assert ( + self.integration_request and self.tx_data and self.mandate + ), "Do not invoke _initiate_mandate_acquisition directly. It should be invoked by proceed" + raise NotImplementedError + + def _initiate_mandated_charge(self): + """Invoked by proceed 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. + + It also has access to the mandate document via self.mandate. + + Returns: {} - gateway specific frontend data + """ + assert ( + self.integration_request and self.tx_data and self.mandate + ), "Do not invoke _initiate_mandated_charge directly. It should be invoked by proceed" + raise NotImplementedError + + def _initiate_charge(self): + """Invoked by proceed 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: {} - gateway specific frontend data + """ + assert ( + self.integration_request and self.tx_data + ), "Do not invoke _initiate_charge directly. It should be invoked by proceed" + raise NotImplementedError + + def _validate_response_payload(self): + raise NotImplementedError + + def validate_response_payload(self): + """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 = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _("There's been an issue with your payment."), + http_status_code=500, + indicator_color="red", + ) + + def _process_response_for_mandate_acquisition(self): + raise NotImplementedError + + @decorate_process_response + def process_response_for_mandate_acquisition(self, payload): + """Invoked by the mandate acquisition flow coming from either the client (e.g. from ./checkout) or the gateway server (IPN). + + Implementations can read/write: + + - self.integration_request + - self.tx_data + - self.response_paymload + - self.mandate + - self.ref_doc + + Parameters: + integration_request_name (str): tx reference (added via and consumed by decorator) + payload (dict): return payload from the flow (will be validated by decorator with validate_payload) + + Returns: (None or dict) Indicating the customer facing message and next action (redirect) + """ + self.mandate = self._get_mandate() + + return_value = None + + try: + return_value = self._process_response_for_mandate_acquisition() + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, "mandate acquisition"), + http_status_code=500, + indicator_color="red", + ) + + + assert ( + self.flags.status_changed_to + ), "_process_response_for_mandate_acquisition must set self.flags.status_changed_to" + + + try: + if self.ref_doc.hasattr("on_payment_mandate_acquisition_processed"): + return_value = self.ref_doc.run_method( + "on_payment_mandate_acquisition_processed", self.flags.status_changed_to + ) or return_value + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, "mandate acquisition (via ref doc hook)"), + http_status_code=500, + indicator_color="red", + ) + + + if self.flags.status_changed_to in self.success_flags: + self.integration_request.handle_success(self.response_payload) + return 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: + self.integration_request.handle_success(self.response_payload) + self.integration_request.db_set("status", "Authorized", update_modified=False) + return return_value or { + "message": _("Payment mandate successfully authorized"), + "action": {"redirect_to": "/"}, + } + else: + self.integration_request.handle_failure(self.response_payload) + return return_value or { + "message": _("Payment mandate acquisition failed"), + "action": {"redirect_to": "/"}, + } + + def _process_response_for_mandated_charge(self, payload): + raise NotImplementedError + + @decorate_process_response + def process_response_for_mandated_charge(self, payload): + """Invoked by the mandated charge flow coming from either the client (e.g. from ./checkout) or the gateway server (IPN). + + Implementations can read/write: + + - self.integration_request + - self.tx_data + - self.response_paymload + - self.mandate + - self.ref_doc + + Parameters: + integration_request_name (str): tx reference (added via and consumed by decorator) + payload (dict): return payload from the flow (will be validated by decorator with validate_payload) + + Returns: (None or dict) Indicating the customer facing message and next action (redirect) + """ + self.mandate = self._get_mandate() + + return_value = None + + try: + return_value = self._process_response_for_mandated_charge() + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, "mandated charge"), + http_status_code=500, + indicator_color="red", + ) + + assert ( + self.flags.status_changed_to + ), "_process_response_for_mandated_charge must set self.flags.status_changed_to" + + try: + if self.ref_doc.hasattr("on_payment_mandated_charge_processed"): + return_value = self.ref_doc.run_method( + "on_payment_mandated_charge_processed", self.flags.status_changed_to + ) or return_value + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, "mandated charge (via ref doc hook)"), + http_status_code=500, + indicator_color="red", + ) + + if self.flags.status_changed_to in self.success_flags: + self.integration_request.handle_success(self.response_payload) + return return_value or { + "message": _("Payment mandate charge succeeded"), + "action": {"redirect_to": "/"}, + } + else: + self.integration_request.handle_failure(self.response_payload) + return return_value or { + "message": _("Payment mandate charge failed"), + "action": {"redirect_to": "/"}, + } + + def _process_response_for_charge(self, payload): + raise NotImplementedError + + @decorate_process_response + def process_response_for_charge(self, payload): + """Invoked by the charge flow coming from either the client (e.g. from ./checkout) or the gateway server (IPN). + + Implementations can read/write: + + - self.integration_request + - self.tx_data + - self.response_paymload + - self.ref_doc + + Parameters: + integration_request_name (str): tx reference (added via and consumed by decorator) + payload (dict): return payload from the flow (will be validated by decorator with validate_payload) + + Returns: (None or dict) Indicating the customer facing message and next action (redirect) + """ + + return_value = None + + try: + return_value = self._process_response_for_charge() + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, "charge"), + http_status_code=500, + indicator_color="red", + ) + + assert ( + self.flags.status_changed_to + ), "_process_response_for_charge must set self.flags.status_changed_to" + + try: + if self.ref_doc.hasattr("on_payment_charge_processed"): + return_value = self.ref_doc.run_method( + "on_payment_charge_processed", self.flags.status_changed_to + ) or return_value + except Exception: + error = self.integration_request.log_error(frappe.get_traceback()) + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, "charge (via ref doc hook)"), + http_status_code=500, + indicator_color="red", + ) + + if self.flags.status_changed_to in self.success_flags: + self.integration_request.handle_success(self.response_payload) + return return_value or { + "message": _("Payment charge succeeded"), + "action": {"redirect_to": "/"}, + } + else: + self.integration_request.handle_failure(self.response_payload) + return return_value or { + "message": _("Payment charge failed"), + "action": {"redirect_to": "/"}, + }