From 641ee805374e5a3c7b6adc140806e03694eeb769 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 3 May 2024 14:41:23 +0200 Subject: [PATCH 01/31] feat: add new payment controller --- payments/controllers/__init__.py | 1 + payments/controllers/payment_controller.py | 670 ++++++++++++++++++ payments/exceptions.py | 23 + payments/hooks.py | 2 + payments/overrides/payment_webform.py | 4 +- payments/payments/doctype/__init__.py | 0 .../doctype/payment_button/__init__.py | 0 .../doctype/payment_button/payment_button.js | 17 + .../payment_button/payment_button.json | 142 ++++ .../doctype/payment_button/payment_button.py | 55 ++ .../payment_button/test_payment_button.py | 9 + .../doctype/payment_session_log/__init__.py | 0 .../payment_session_log.js | 22 + .../payment_session_log.json | 139 ++++ .../payment_session_log.py | 191 +++++ .../payment_session_log_list.js | 21 + .../test_payment_session_log.py | 9 + payments/types.py | 205 ++++++ payments/utils/__init__.py | 3 +- payments/utils/utils.py | 33 +- payments/www/__init__.py | 0 payments/www/pay.css | 8 + payments/www/pay.html | 73 ++ payments/www/pay.js | 66 ++ payments/www/pay.py | 108 +++ 25 files changed, 1795 insertions(+), 6 deletions(-) create mode 100644 payments/controllers/__init__.py create mode 100644 payments/controllers/payment_controller.py create mode 100644 payments/exceptions.py create mode 100644 payments/payments/doctype/__init__.py create mode 100644 payments/payments/doctype/payment_button/__init__.py create mode 100644 payments/payments/doctype/payment_button/payment_button.js create mode 100644 payments/payments/doctype/payment_button/payment_button.json create mode 100644 payments/payments/doctype/payment_button/payment_button.py create mode 100644 payments/payments/doctype/payment_button/test_payment_button.py create mode 100644 payments/payments/doctype/payment_session_log/__init__.py create mode 100644 payments/payments/doctype/payment_session_log/payment_session_log.js create mode 100644 payments/payments/doctype/payment_session_log/payment_session_log.json create mode 100644 payments/payments/doctype/payment_session_log/payment_session_log.py create mode 100644 payments/payments/doctype/payment_session_log/payment_session_log_list.js create mode 100644 payments/payments/doctype/payment_session_log/test_payment_session_log.py create mode 100644 payments/types.py create mode 100644 payments/www/__init__.py create mode 100644 payments/www/pay.css create mode 100644 payments/www/pay.html create mode 100644 payments/www/pay.js create mode 100644 payments/www/pay.py diff --git a/payments/controllers/__init__.py b/payments/controllers/__init__.py new file mode 100644 index 00000000..d101171b --- /dev/null +++ b/payments/controllers/__init__.py @@ -0,0 +1 @@ +from .payment_controller import PaymentController, frontend_defaults diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py new file mode 100644 index 00000000..9daae9e8 --- /dev/null +++ b/payments/controllers/payment_controller.py @@ -0,0 +1,670 @@ +import json + +from urllib.parse import urlencode + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.model.base_document import get_controller +from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.desk.form.load import get_automatic_email_link, get_document_email +from payments.payments.doctype.payment_session_log.payment_session_log import ( + create_log, +) +from frappe.utils import get_url + +from payments.utils import PAYMENT_SESSION_REF_KEY + +from types import MappingProxyType + +from payments.exceptions import ( + FailedToInitiateFlowError, + PayloadIntegrityError, + PaymentControllerProcessingError, + RefDocHookProcessingError, +) + +from payments.types import ( + Initiated, + TxData, + _Processed, + Processed, + PSLName, + PaymentUrl, + PaymentMandate, + SessionType, + Proceeded, + RemoteServerInitiationPayload, + GatewayProcessingResponse, + SessionStates, + FrontendDefaults, + ActionAfterProcessed, +) + +from typing import TYPE_CHECKING, Optional, overload +from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, +) + +if TYPE_CHECKING: + from payments.payments.doctype.payment_gateway.payment_gateway import PaymentGateway + + +def _error_value(error, flow): + return _( + "Our server had an issue processing your {0}. Please contact customer support mentioning: {1}" + ).format(flow, error) + + +def _help_me_develop(state): + from pprint import pprint + + print("self.state: ") + pprint(state) + + +class PaymentController(Document): + """This controller implemets the public API of payment gateway controllers.""" + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + frontend_defaults: FrontendDefaults + flowstates: SessionStates + + def __new__(cls, *args, **kwargs): + assert hasattr(cls, "flowstates") and isinstance( + cls.flowstates, SessionStates + ), """the controller must declare its flow states in `cls.flowstates` + and it must be an instance of payments.types.SessionStates + """ + assert hasattr(cls, "frontend_defaults") and isinstance( + cls.frontend_defaults, FrontendDefaults + ), """the controller must declare its flow states in `cls.frontend_defaults` + and it must be an instance of payments.types.FrontendDefaults + """ + return super().__new__(cls) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.state = frappe._dict() + + @overload + @staticmethod + def initiate( + tx_data: TxData, gateway: str, correlation_id: str | None, name: str | None + ) -> PSLName: + ... + + @staticmethod + def initiate( + tx_data: TxData, + gateway: "PaymentController" = None, + correlation_id: str = None, + name: str = None, + ) -> ("PaymentController", PSLName): + """Initiate a payment flow from Ref Doc with the given gateway. + + Inheriting methods can invoke super and then set e.g. correlation_id on self.state.psl to save + and early-obtained correlation id from the payment gateway or to initiate the user flow if delegated to + the controller (see: is_user_flow_initiation_delegated) + """ + if isinstance(gateway, str): + payment_gateway: PaymentGateway = frappe.get_cached_doc("Payment Gateway", gateway) + + if not payment_gateway.gateway_controller and not payment_gateway.gateway_settings: + frappe.throw( + _( + "{0} is not fully configured, both Gateway Settings and Gateway Controller need to be set" + ).format(gateway) + ) + + self = frappe.get_cached_doc( + payment_gateway.gateway_settings, + payment_gateway.gateway_controller or payment_gateway.gateway_settings, # may be a singleton + ) + else: + self = gateway + + self.validate_tx_data(tx_data) # preflight check + + psl = create_log( + tx_data=tx_data, + controller=self, + status="Created", + ) + return self, psl.name + + @staticmethod + def get_payment_url(psl_name: PSLName) -> PaymentUrl | None: + """Use the payment url to initiate the user flow, for example via email or chat message. + + Beware, that the controller might not implement this and in that case return: None + """ + params = { + PAYMENT_SESSION_REF_KEY: psl_name, + } + return get_url(f"./pay?{urlencode(params)}") + + @staticmethod + def proceed(psl_name: PSLName, updated_tx_data: TxData | None) -> Proceeded: + """Call this when the user agreed to proceed with the payment to initiate the capture with + the remote payment gateway. + + If the capture is initialized by the gatway, call this immediatly without waiting for the + user OK signal. + + updated_tx_data: + Pass any update to the inital transaction data; this can reflect later customer choices + and thereby modify the flow + + Example: + ```python + if controller.is_user_flow_initiation_delegated(): + controller.proceed() + else: + # example (depending on the doctype & business flow): + # 1. send email with payment link + # 2. let user open the link + # 3. upon rendering of the page: call proceed; potentially with tx updates + pass + ``` + """ + + psl: PaymentSessionLog = frappe.get_cached_doc("Payment Session Log", psl_name) + self: "PaymentController" = psl.get_controller() + + psl.update_tx_data(updated_tx_data or {}, "Started") # commits + + self.state = psl.load_state() + # controller specific temporary modifications + self.state.tx_data = self._patch_tx_data(self.state.tx_data) + self.state.mandate: PaymentMandate = self._get_mandate() + + try: + + if self._should_have_mandate() and not self.mandate: + self.state.mandate = self._create_mandate() + initiated = self._initiate_mandate_acquisition() + psl.db_set( + { + "processing_response_payload": None, # in case of a reset + "flow_type": SessionType.mandate_acquisition, + "correlation_id": initiated.correlation_id, + "mandate": f"{self.state.mandate.doctype}[{self.state.mandate.name}]", + }, + # commit=True, + ) + psl.set_initiation_payload(initiated.payload, "Initiated") # commits + return Proceeded( + integration=self.doctype, + psltype=SessionType.mandate_acquisition, + mandate=self.state.mandate, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + elif self.state.mandate: + initiated = self._initiate_mandated_charge() + psl.db_set( + { + "processing_response_payload": None, # in case of a reset + "flow_type": SessionType.mandated_charge, + "correlation_id": initiated.correlation_id, + "mandate": f"{self.state.mandate.doctype}[{self.state.mandate.name}]", + }, + # commit=True, + ) + psl.set_initiation_payload(initiated.payload, "Initiated") # commits + return Proceeded( + integration=self.doctype, + psltype=SessionType.mandated_charge, + mandate=self.state.mandate, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + else: + initiated = self._initiate_charge() + psl.db_set( + { + "processing_response_payload": None, # in case of a reset + "flow_type": SessionType.charge, + "correlation_id": initiated.correlation_id, + }, + # commit=True, + ) + psl.set_initiation_payload(initiated.payload, "Initiated") # commits + return Proceeded( + integration=self.doctype, + psltype=SessionType.charge, + mandate=None, + txdata=self.state.tx_data, + payload=initiated.payload, + ) + + except FailedToInitiateFlowError as e: + psl.set_initiation_payload(e.data, "Error") + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("Please contact customer care mentioning: {0}").format(psl), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + except Exception as e: + error = psl.log_error(title="Unknown Initialization Failure") + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("Please contact customer care mentioning: {0}").format(error), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + + def __process_response( + self, + psl: PaymentSessionLog, + response: GatewayProcessingResponse, + ref_doc: Document, + callable, + hookmethod, + psltype, + ) -> Processed | None: + processed = None + try: + processed = callable() # idempotent on second run + except Exception: + raise PaymentControllerProcessingError(f"{callable} failed", psltype) + + assert self.flags.status_changed_to in ( + self.flowstates.success + + self.flowstates.pre_authorized + + self.flowstates.processing + + self.flowstates.declined + ), "self.flags.status_changed_to must be in the set of possible states for this controller:\n - {}".format( + "\n - ".join( + self.flowstates.success + + self.flowstates.pre_authorized + + self.flowstates.processing + + self.flowstates.declined + ) + ) + + ret = { + "status_changed_to": self.flags.status_changed_to, + "payload": response.payload, + } + + try: + if res := ref_doc.run_method( + hookmethod, + self.state, + MappingProxyType(self.flags), + ): + # type check the result value on user implementations + res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ + _res = _Processed(**res) + processed = Processed(**(ret | _res.__dict__)) + except Exception: + raise RefDocHookProcessingError(f"{hookmethod} failed", psltype) + + if self.flags.status_changed_to in self.flowstates.success: + psl.set_processing_payload(response, "Paid") + ret["indicator_color"] = "green" + processed = processed or Processed( + message=_("{} succeeded").format(psltype.title()), + action=dict(href="/", label=_("Go to Homepage")), + **ret, + ) + elif self.flags.status_changed_to in self.flowstates.pre_authorized: + psl.set_processing_payload(response, "Authorized") + ret["indicator_color"] = "green" + processed = processed or Processed( + message=_("{} authorized").format(psltype.title()), + action=dict(href="/", label=_("Go to Homepage")), + **ret, + ) + elif self.flags.status_changed_to in self.flowstates.processing: + psl.set_processing_payload(response, "Processing") + ret["indicator_color"] = "yellow" + processed = processed or Processed( + message=_("{} awaiting further processing by the bank").format(psltype.title()), + action=dict(href="/", label=_("Refresh")), + **ret, + ) + elif self.flags.status_changed_to in self.flowstates.declined: + psl.db_set("decline_reason", self._render_failure_message()) + psl.set_processing_payload(response, "Declined") # commits + ret["indicator_color"] = "red" + incoming_email = None + if automatic_linking_email := get_automatic_email_link(): + incoming_email = get_document_email( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + if incoming_email := incoming_email or EmailAccount.find_default_incoming(): + subject = _("Payment declined for: {}").format(self.state.tx_data.reference_docname) + body = _("Please help me with ref '{}'").format(psl.name) + href = "mailto:{incoming_email.email_id}?subject={subject}" + action = dict(href=href, label=_("Email Us")) + else: + action = dict(href=self.get_payment_url(psl.name), label=_("Refresh")) + processed = processed or Processed( + message=_("{} declined").format(psltype.title()), + action=action, + **ret, + ) + + return processed + + def _process_response( + self, psl: PaymentSessionLog, response: GatewayProcessingResponse, ref_doc: Document + ) -> Processed: + self._validate_response() + + match psl.flow_type: + case SessionType.mandate_acquisition: + self.state.mandate: PaymentMandate = self._get_mandate() + processed: Processed = self.__process_response( + psl=psl, + response=response, + ref_doc=ref_doc, + callable=self._process_response_for_mandate_acquisition, + hookmethod="on_payment_mandate_acquisition_processed", + psltype="mandate adquisition", + ) + case SessionType.mandated_charge: + self.state.mandate: PaymentMandate = self._get_mandate() + processed: Processed = self.__process_response( + psl=psl, + response=response, + ref_doc=ref_doc, + callable=self._process_response_for_mandated_charge, + hookmethod="on_payment_mandated_charge_processed", + psltype="mandated charge", + ) + case SessionType.charge: + processed: Processed = self.__process_response( + psl=psl, + response=response, + ref_doc=ref_doc, + callable=self._process_response_for_charge, + hookmethod="on_payment_charge_processed", + psltype="charge", + ) + + return processed + + @staticmethod + def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> Processed: + """Call this from the controlling business logic; either backend or frontens. + + It will recover the correct controller and dispatch the correct processing based on data that is at this + point already stored in the integration log + + payload: + this is a signed, sensitive response containing the payment status; the signature is validated prior + to processing by controller._validate_response + """ + + psl: PaymentSessionLog = frappe.get_cached_doc("Payment Session Log", psl_name) + self: "PaymentController" = psl.get_controller() + + # guard against already currently being processed payloads via another entrypoint + if psl.is_locked: + psl.lock(timeout=5) # allow ample 5 seconds to finish + psl.reload() + else: + psl.lock() + + self.state = psl.load_state() + self.state.response = response + + ref_doc = frappe.get_doc( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + + mute = self._is_server_to_server() + try: + processed = self._process_response(psl, response, ref_doc) + if self.flags.status_changed_to in self.flowstates.declined: + msg = self._render_failure_message() + psl.db_set("failure_reason", msg, commit=True) + try: + status = self.flags.status_changed_to + ref_doc.run_method("on_payment_failed", status, msg) + except Exception: + psl.log_error("Setting failure message on ref doc failed") + + except PayloadIntegrityError: + error = psl.log_error("Response validation failure") + if not mute: + frappe.redirect_to_message( + _("Server Error"), + _("There's been an issue with your payment."), + http_status_code=500, + indicator_color="red", + ) + raise frappe.Redirect + + except PaymentControllerProcessingError as e: + error = psl.log_error(f"Processing error ({e.psltype})") + psl.set_processing_payload(response, "Error") + if not mute: + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, e.psltype), + http_status_code=500, + indicator_color="red", + ) + raise frappe.Redirect + + except RefDocHookProcessingError as e: + error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)") + psl.set_processing_payload(response, "Error - RefDoc") + if not mute: + frappe.redirect_to_message( + _("Server Error"), + _error_value(error, f"{e.psltype} (via ref doc hook)"), + http_status_code=500, + indicator_color="red", + ) + raise frappe.Redirect + else: + return processed + finally: + psl.unlock() + + # Lifecycle hooks (contracts) + # - imeplement them for your controller + # --------------------------------------- + + def validate_tx_data(self, tx_data: TxData) -> None: + """Invoked by the reference document for example in order to validate the transaction data. + + Should throw on error with an informative user facing message. + """ + raise NotImplementedError + + def is_user_flow_initiation_delegated(self, psl_name: PSLName) -> bool: + """If true, you should initiate the user flow from the Ref Doc. + + For example, by sending an email (with a payment url), letting the user make a phone call or initiating a factoring process. + + If false, the gateway initiates the user flow. + """ + return False + + # Concrete controller methods + # - imeplement them for your gateway + # --------------------------------------- + + def _patch_tx_data(self, tx_data: TxData) -> TxData: + """Optional: Implement tx_data preprocessing if required by the gateway. + For example in order to fix rounding or decimal accuracy. + """ + return tx_data + + def _should_have_mandate(self) -> bool: + """Optional: Define here, if the TxData store in self.state.tx_data should have a mandate. + + If yes, and the controller hasn't yet found one from a call to self._get_mandate(), + it will initiate the adquisition of a new mandate in self._create_mandate(). + + You have read (!) access to: + - self.state.psl + - self.state.tx_data + """ + assert self.state.psl + assert self.state.tx_data + return False + + def _get_mandate(self) -> PaymentMandate: + """Optional: Define here, how to fetch this controller's mandate doctype instance. + + Since a mandate might be highly controller specific, this is its accessor. + + You have read (!) access to: + - self.state.psl + - self.state.tx_data + """ + assert self.state.psl + assert self.state.tx_data + return None + + def _create_mandate(self) -> PaymentMandate: + """Optional: Define here, how to create controller's mandate doctype instance. + + Since a mandate might be highly controller specific, this is its constructor. + + You have read (!) access to: + - self.state.psl + - self.state.tx_data + """ + assert self.state.psl + assert self.state.tx_data + _help_me_develop(self.state) + return None + + def _initiate_mandate_acquisition(self) -> Initiated: + """Invoked by proceed to initiate a mandate acquisiton flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _initiate_mandated_charge(self) -> Initiated: + """Invoked by proceed or after having aquired a mandate in order to initiate a mandated charge flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _initiate_charge(self) -> Initiated: + """Invoked by proceed in order to initiate a charge flow. + + Implementations can read: + - self.state.psl + - self.state.tx_data + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _validate_response(self) -> None: + """Implement how the validation of the response signature + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _process_response_for_mandate_acquisition(self) -> Processed | None: + """Implement how the controller should process mandate acquisition responses + + Needs to be idenmpotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _process_response_for_mandated_charge(self) -> Processed | None: + """Implement how the controller should process mandated charge responses + + Needs to be idenmpotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + + Implementations can read/write: + - self.state.mandate + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _process_response_for_charge(self) -> Processed | None: + """Implement how the controller should process charge responses + + Needs to be idenmpotent. + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _render_failure_message(self) -> str: + """Extract a readable failure message out of the server response + + Implementations can read: + - self.state.psl + - self.state.tx_data + - self.state.response + - self.state.mandate; if mandate is involved + """ + _help_me_develop(self.state) + raise NotImplementedError + + def _is_server_to_server(self) -> bool: + """If this is a server to server processing flow. + + In this case, no errors will be returned. + + Implementations can read: + - self.state.response + """ + _help_me_develop(self.state) + raise NotImplementedError + + +@frappe.whitelist() +def frontend_defaults(doctype): + c: PaymentController = get_controller(doctype) + if issubclass(c, PaymentController): + d: FrontendDefaults = c.frontend_defaults + return d.__dict__ diff --git a/payments/exceptions.py b/payments/exceptions.py new file mode 100644 index 00000000..51d194f1 --- /dev/null +++ b/payments/exceptions.py @@ -0,0 +1,23 @@ +from frappe.exceptions import ValidationError + + +class FailedToInitiateFlowError(Exception): + def __init__(self, message, data): + self.message = message + self.data = data + + +class PayloadIntegrityError(ValidationError): + pass + + +class PaymentControllerProcessingError(Exception): + def __init__(self, message, psltype): + self.message = message + self.psltype = psltype + + +class RefDocHookProcessingError(Exception): + def __init__(self, message, psltype): + self.message = message + self.psltype = psltype diff --git a/payments/hooks.py b/payments/hooks.py index caa07d27..31705812 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -179,3 +179,5 @@ # Recommended only for DocTypes which have limited documents with untranslated names # For example: Role, Gender, etc. # translated_search_doctypes = [] + +export_python_type_annotations = True diff --git a/payments/overrides/payment_webform.py b/payments/overrides/payment_webform.py index 3b2d7afa..c99d0555 100644 --- a/payments/overrides/payment_webform.py +++ b/payments/overrides/payment_webform.py @@ -6,7 +6,7 @@ from frappe.utils import flt from frappe.website.doctype.web_form.web_form import WebForm -from payments.utils import get_payment_gateway_controller +from payments.utils import get_payment_controller class PaymentWebForm(WebForm): @@ -24,7 +24,7 @@ def validate_payment_amount(self): def get_payment_gateway_url(self, doc): if getattr(self, "accept_payment", False): - controller = get_payment_gateway_controller(self.payment_gateway) + controller = get_payment_controller(self.payment_gateway) title = f"Payment for {doc.doctype} {doc.name}" amount = self.amount diff --git a/payments/payments/doctype/__init__.py b/payments/payments/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payments/doctype/payment_button/__init__.py b/payments/payments/doctype/payment_button/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payments/doctype/payment_button/payment_button.js b/payments/payments/doctype/payment_button/payment_button.js new file mode 100644 index 00000000..1f2fd3b1 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.js @@ -0,0 +1,17 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Payment Button', { + gateway_settings: function(frm) { + const val = frm.get_field("gateway_settings").value; + if (val) { + frappe.call("payments.controllers.frontend_defaults", {doctype: val}, (r) => { + if (r.message) { + frm.set_value('gateway_css', r.message.gateway_css ) + frm.set_value('gateway_js', r.message.gateway_js) + frm.set_value('gateway_wrapper', r.message.gateway_wrapper) + } + }) + } + } +}); diff --git a/payments/payments/doctype/payment_button/payment_button.json b/payments/payments/doctype/payment_button/payment_button.json new file mode 100644 index 00000000..45e62dd2 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -0,0 +1,142 @@ +{ + "actions": [], + "autoname": "field:label", + "creation": "2022-01-24 21:09:47.229371", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway_settings", + "gateway_controller", + "column_break_mjuo", + "label", + "enabled", + "button_configuration_section", + "column_break_zwhf", + "icon", + "gateway_css", + "gateway_js", + "gateway_wrapper", + "extra_payload" + ], + "fields": [ + { + "fieldname": "gateway_settings", + "fieldtype": "Link", + "label": "Gateway Settings", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "gateway_controller", + "fieldtype": "Dynamic Link", + "label": "Gateway Controller", + "options": "gateway_settings", + "reqd": 1 + }, + { + "default": "\n", + "fieldname": "gateway_css", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Gateway CSS", + "mandatory_depends_on": "eval: doc.enabled", + "options": "HTML" + }, + { + "default": "\n", + "fieldname": "gateway_js", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Gateway JS", + "mandatory_depends_on": "eval: doc.enabled", + "options": "HTML" + }, + { + "default": "
\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n
", + "fieldname": "gateway_wrapper", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Gateway Wrapper", + "mandatory_depends_on": "eval: doc.enabled", + "options": "HTML" + }, + { + "default": "
\n
\n
\n \n\n \n
\n
\n
\n \n
\n
\n
\n
\n\n\n", + "fieldname": "column_break_zwhf", + "fieldtype": "Column Break" + }, + { + "description": "The label to show on the payment button on checkout pages", + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_mjuo", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "description": "The svg icon to the left of the payment button label", + "fieldname": "icon", + "fieldtype": "Attach Image", + "label": "Icon" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.enabled", + "description": "The code fields of this section are HTML snippets templated via jinja.
\n
{\n  \"doc\":     <Instance of PaymentController>,\n  \"payload\": <Instance of RemoteServerInitiationPayload>,\n}
\nThis jinja context is specific to the gateway.", + "fieldname": "button_configuration_section", + "fieldtype": "Section Break", + "label": "Button Configuration" + }, + { + "default": "{}", + "description": "Add button-specific extra payload which can be used by the gateway implementation.", + "fieldname": "extra_payload", + "fieldtype": "Code", + "label": "Extra Payload", + "options": "JSON" + } + ], + "image_field": "icon", + "links": [], + "modified": "2024-05-04 05:08:57.275983", + "modified_by": "Administrator", + "module": "Payments", + "name": "Payment Button", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "read": 1, + "role": "System Manager", + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Guest", + "select": 1, + "share": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/payments/payments/doctype/payment_button/payment_button.py b/payments/payments/doctype/payment_button/payment_button.py new file mode 100644 index 00000000..e99375b2 --- /dev/null +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -0,0 +1,55 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import frappe +import json +from frappe import _ +from frappe.model.document import Document +from payments.types import RemoteServerInitiationPayload + +Css = str +Js = str +Wrapper = str + + +class PaymentButton(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + enabled: DF.Check + extra_payload: DF.Code | None + gateway_controller: DF.DynamicLink + gateway_css: DF.Code | None + gateway_js: DF.Code | None + gateway_settings: DF.Link + gateway_wrapper: DF.Code | None + icon: DF.AttachImage | None + label: DF.Data + # end: auto-generated types + + # Frontend Assets (widget) + # - imeplement them for your controller + # - need to be fully rendered with + # --------------------------------------- + def get_assets(self, payload: RemoteServerInitiationPayload) -> (Css, Js, Wrapper): + """Get the fully rendered frontend assets for this button.""" + context = { + "doc": frappe.get_cached_doc(self.gateway_settings, self.gateway_controller), + "payload": payload, + } + css = frappe.render_template(self.gateway_css, context) + js = frappe.render_template(self.gateway_js, context) + wrapper = frappe.render_template(self.gateway_wrapper, context) + return css, js, wrapper + + def validate(self): + if self.extra_payload: + try: + json.loads(self.extra_payload) + except Exception: + frappe.throw(_("Extra Payload must be valid JSON.")) diff --git a/payments/payments/doctype/payment_button/test_payment_button.py b/payments/payments/doctype/payment_button/test_payment_button.py new file mode 100644 index 00000000..31b1c933 --- /dev/null +++ b/payments/payments/doctype/payment_button/test_payment_button.py @@ -0,0 +1,9 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE +import unittest + +# test_records = frappe.get_test_records('Payment Button') + + +class TestPaymentButton(unittest.TestCase): + pass diff --git a/payments/payments/doctype/payment_session_log/__init__.py b/payments/payments/doctype/payment_session_log/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.js b/payments/payments/doctype/payment_session_log/payment_session_log.js new file mode 100644 index 00000000..d2157e82 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.js @@ -0,0 +1,22 @@ +// Copyright (c) 2021, Frappe and contributors +// For license information, please see LICENSE + +frappe.ui.form.on('Payment Session Log', { + refresh: function(frm) { + if (frm.doc.request_data && ['Error', 'Error - RefDoc'].includes(frm.doc.status)){ + frm.add_custom_button(__('Retry'), function() { + frappe.call({ + method:"payments.payments.doctype.payment_session_log.payment_session_log.resync", + args:{ + method:frm.doc.method, + name: frm.doc.name, + request_data: frm.doc.request_data + }, + callback: function(r){ + frappe.msgprint(__("Reattempting to sync")) + } + }) + }).addClass('btn-primary'); + } + } +}); diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.json b/payments/payments/doctype/payment_session_log/payment_session_log.json new file mode 100644 index 00000000..3f11ec48 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.json @@ -0,0 +1,139 @@ +{ + "actions": [], + "creation": "2021-04-15 12:29:03.541492", + "doctype": "DocType", + "document_type": "System", + "engine": "InnoDB", + "field_order": [ + "title", + "status", + "decline_reason", + "button", + "flow_type", + "gateway", + "mandate", + "correlation_id", + "tx_data", + "initiation_response_payload", + "processing_response_payload" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "hidden": 1, + "label": "Title", + "read_only": 1 + }, + { + "default": "Queued", + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "read_only": 1 + }, + { + "fieldname": "gateway", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Gateway", + "read_only": 1 + }, + { + "fieldname": "tx_data", + "fieldtype": "Code", + "label": "Tx Data", + "read_only": 1 + }, + { + "fieldname": "correlation_id", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Correlation ID", + "read_only": 1 + }, + { + "fieldname": "flow_type", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Flow Type", + "read_only": 1 + }, + { + "fieldname": "mandate", + "fieldtype": "Data", + "label": "Mandate", + "read_only": 1 + }, + { + "fieldname": "button", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Button", + "read_only": 1 + }, + { + "fieldname": "initiation_response_payload", + "fieldtype": "Code", + "label": "Initiation Response Payload", + "read_only": 1 + }, + { + "fieldname": "processing_response_payload", + "fieldtype": "Code", + "label": "Processing Response Payload", + "read_only": 1 + }, + { + "fieldname": "decline_reason", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Decline Reason", + "read_only": 1 + } + ], + "in_create": 1, + "links": [], + "modified": "2024-04-15 07:16:05.889449", + "modified_by": "Administrator", + "module": "Payments", + "name": "Payment Session Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "title_field": "title" +} \ No newline at end of file diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.py b/payments/payments/doctype/payment_session_log/payment_session_log.py new file mode 100644 index 00000000..296d93e1 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log.py @@ -0,0 +1,191 @@ +# Copyright (c) 2021, Frappe and contributors +# For license information, please see LICENSE + +import json + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.query_builder import Interval +from frappe.query_builder.functions import Now +from frappe.utils import strip_html +from frappe.utils.data import cstr + +from payments.types import TxData, RemoteServerInitiationPayload, GatewayProcessingResponse +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from payments.controllers import PaymentController + from payments.payments.doctype.payment_button.payment_button import PaymentButton + + +class PaymentSessionLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + button: DF.Data | None + correlation_id: DF.Data | None + decline_reason: DF.Data | None + flow_type: DF.Data | None + gateway: DF.Data | None + initiation_response_payload: DF.Code | None + mandate: DF.Data | None + processing_response_payload: DF.Code | None + status: DF.Data | None + title: DF.Data | None + tx_data: DF.Code | None + # end: auto-generated types + def update_tx_data(self, tx_data: TxData, status: str) -> None: + data = json.loads(self.tx_data) + data.update(tx_data) + self.db_set( + { + "tx_data": frappe.as_json(data), + "status": status, + }, + commit=True, + ) + + def set_initiation_payload( + self, initiation_payload: RemoteServerInitiationPayload, status: str + ) -> None: + self.db_set( + { + "initiation_response_payload": frappe.as_json(initiation_payload), + "status": status, + }, + commit=True, + ) + + def set_processing_payload( + self, processing_response: GatewayProcessingResponse, status: str + ) -> None: + self.db_set( + { + "processing_response_payload": frappe.as_json(processing_response.payload), + "status": status, + }, + commit=True, + ) + + def load_state(self): + return frappe._dict( + psl=frappe._dict(self.as_dict()), + tx_data=TxData(**json.loads(self.tx_data)), + ) + + def get_controller(self) -> "PaymentController": + """For perfomance reasons, this is not implemented as a dynamic link but a json value + so that it is only fetched when absolutely necessary. + """ + if not self.gateway: + self.log_error("No gateway selected yet") + frappe.throw(_("No gateway selected for this payment session")) + d = json.loads(self.gateway) + doctype, docname = d["gateway_settings"], d["gateway_controller"] + return frappe.get_cached_doc(doctype, docname) + + def get_button(self) -> "PaymentButton": + if not self.button: + self.log_error("No button selected yet") + frappe.throw(_("No button selected for this payment session")) + return frappe.get_cached_doc("Payment Button", self.button) + + @staticmethod + def clear_old_logs(days=90): + table = frappe.qb.DocType("Payment Session Log") + frappe.db.delete( + table, filters=(table.modified < (Now() - Interval(days=days))) & (table.status == "Success") + ) + + +@frappe.whitelist(allow_guest=True) +def select_button(pslName: str = None, buttonName: str = None) -> str: + try: + psl = frappe.get_cached_doc("Payment Session Log", pslName) + except Exception: + e = frappe.log_error("Payment Session Log not found", reference_doctype="Payment Session Log") + # Ensure no more details are leaked than the error log reference + frappe.local.message_log = [_("Server Failure!
{}").format(e)] + return + try: + btn: PaymentButton = frappe.get_cached_doc("Payment Button", buttonName) + except Exception: + e = frappe.log_error("Payment Button not found", reference_doctype="Payment Button") + # Ensure no more details are leaked than the error log reference + frappe.local.message_log = [_("Server Failure!
{}").format(e)] + return + + psl.db_set( + { + "button": buttonName, + "gateway": json.dumps( + { + "gateway_settings": btn.gateway_settings, + "gateway_controller": btn.gateway_controller, + } + ), + } + ) + # once state set: reload the page to activate widget + return {"reload": True} + + +def create_log( + tx_data: TxData, + controller: "PaymentController" = None, + status: str = "Created", +) -> PaymentSessionLog: + + log = frappe.new_doc("Payment Session Log") + log.tx_data = frappe.as_json(tx_data) + log.status = status + if controller: + log.gateway = json.dumps( + { + "gateway_settings": controller.doctype, + "gateway_controller": controller.name, + } + ) + + log.insert(ignore_permissions=True) + return log + + +@frappe.whitelist() +def resync(method, name, request_data): + _retry_job(name) + + +def _retry_job(job: str): + frappe.only_for("System Manager") + + doc = frappe.get_doc("Payment Session Log", job) + if not doc.method.startswith("payments.payment_gateways.") or doc.status != "Error": + return + + doc.db_set("status", "Queued", update_modified=False) + doc.db_set("traceback", "", update_modified=False) + + frappe.enqueue( + method=doc.method, + queue="short", + timeout=300, + is_async=True, + payload=json.loads(doc.request_data), + request_id=doc.name, + enqueue_after_commit=True, + ) + + +@frappe.whitelist() +def bulk_retry(names): + if isinstance(names, str): + names = json.loads(names) + for name in names: + _retry_job(name) diff --git a/payments/payments/doctype/payment_session_log/payment_session_log_list.js b/payments/payments/doctype/payment_session_log/payment_session_log_list.js new file mode 100644 index 00000000..543f9e6a --- /dev/null +++ b/payments/payments/doctype/payment_session_log/payment_session_log_list.js @@ -0,0 +1,21 @@ +frappe.listview_settings["Payment Session Log"] = { + hide_name_column: true, + add_fields: ["status"], + get_indicator: function (doc) { + if (doc.status === "Success") { + return [__("Success"), "green", "status,=,Success"]; + } else if (doc.status === "Error") { + return [__("Error"), "red", "status,=,Error"]; + } else if (doc.status === "Queued") { + return [__("Queued"), "orange", "status,=,Queued"]; + } + }, + + onload: function (listview) { + listview.page.add_action_item(__("Retry"), () => { + listview.call_for_selected_items( + "payments.payments.doctype.payment_session_log.payment_session_log.bulk_retry" + ); + }); + }, +}; diff --git a/payments/payments/doctype/payment_session_log/test_payment_session_log.py b/payments/payments/doctype/payment_session_log/test_payment_session_log.py new file mode 100644 index 00000000..748c7c91 --- /dev/null +++ b/payments/payments/doctype/payment_session_log/test_payment_session_log.py @@ -0,0 +1,9 @@ +# Copyright (c) 2021, Frappe and Contributors +# See LICENSE + +# import frappe +import unittest + + +class TestPaymentSessionLog(unittest.TestCase): + pass diff --git a/payments/types.py b/payments/types.py new file mode 100644 index 00000000..63deba32 --- /dev/null +++ b/payments/types.py @@ -0,0 +1,205 @@ +from dataclasses import dataclass +from typing import Optional, Dict, List + +from types import MappingProxyType + +from frappe.model.document import Document + +from enum import Enum + + +class SessionType(str, Enum): + """We distinguish three distinct flow types. + + They may be chained into a composed flow from the business logic, for example a + mandate_aquisition can precede a mandated_charge in a continuous flow. + """ + + charge = "charge" + mandated_charge = "mandated_charge" + mandate_acquisition = "mandate_acquisition" + + +@dataclass +class SessionStates: + """Define gateway states in their respective category""" + + success: list[str] + pre_authorized: list[str] + processing: list[str] + declined: list[str] + + +@dataclass +class FrontendDefaults: + """Define gateway frontend defaults for css, js and the wrapper components. + + All three are html snippets and jinja templates rendered against this gateway's + PaymentController instance and its RemoteServerInitiationPayload. + + These are loaded into the Payment Button document and give users a starting point + to customize a gateway's payment button(s) + """ + + gateway_css: str + gateway_js: str + gateway_wrapper: str + + +class RemoteServerInitiationPayload(dict): + """The remote server payload returned during flow initiation. + + Interface: Remote Server -> Concrete Gateway Implementation + Concrete Gateway Implementation -> Payment Gateway Controller + Payment Gateway Controller -> Payment Gateway Controller + """ + + pass + + +@dataclass(frozen=True) +class Initiated: + """The return data structure from a gateway flow initiation. + + Interface: Concrete Gateway Implementation -> Payment Gateway Controller + + correlation_id: + stored as request_id in the integration log to correlate + remote and local request + """ + + correlation_id: str + payload: RemoteServerInitiationPayload + + +@dataclass +class TxData: + """The main data interchange format between refdoc and controller. + + Interface: Ref Doc -> Payment Gateway Controller + + """ + + amount: float + currency: str + reference_doctype: str + reference_docname: str + payer_contact: dict # as: contact.as_dict() + payer_address: dict # as: address.as_dict() + # TODO: tx data for subscriptions, pre-authorized, require-mandate and other flows + + +@dataclass(frozen=True) +class GatewayProcessingResponse: + """The remote server payload returned during flow processing. + + Interface: Remote Server -> Concrete Gateway Implementation + Concrete Gateway Implementation -> Payment Gateway Controller + Payment Gateway Controller -> Payment Gateway Controller + """ + + hash: bytes | None + message: bytes | None + payload: dict + + +class PaymentMandate(Document): + """All payment mandate doctypes should inherit from this base class. + + Interface: Concrete Gateway Implementation -> Payment Gateway Controller + """ + + +@dataclass +class Proceeded: + """The return data structure from a call to proceed() which initiates the flow. + + Interface: Payment Gateway Controller -> calling control flow (backend or frontend) + + integration: + The name of the integration (gateway doctype). + Exposed so that the controlling business flow can case switch on it. + """ + + integration: str + psltype: SessionType + mandate: PaymentMandate | None # TODO: will this be serialized when called from the frontend? + txdata: TxData + payload: RemoteServerInitiationPayload + + +@dataclass +class ActionAfterProcessed: + href: str + label: str + + +@dataclass +class _Processed: + """The return data structure after processing gateway response (by a Ref Doc hook). + + Interface: Ref Doc -> Payment Gateway Controller + + Implementation Note: + If implemented via a server action you may aproximate by using frappe._dict. + + message: + a (translated) message to show to the user + action: + an action for the frontend to perfom + """ + + message: str + action: dict # checked against ActionAfterProcessed + + +@dataclass +class Processed(_Processed): + """The return data structure after processing gateway response. + + Interface: + Payment Gateway Controller -> Calling Buisness Flow (backend or frontend) + + Implementation Note: + If the Ref Doc exposes a hook method, this should return _Processed, instead. + + message: + a (translated) message to show to the user + action: + an action for the frontend to perfom + status_changed_to: + the new status of the payment session after processing + indicator_color: + the new indicator color for visual display of the new status + payload: + a gateway specific payload that is understood by a gateway-specific frontend + implementation + """ + + status_changed_to: str + indicator_color: str + payload: GatewayProcessingResponse + + +# for nicer DX using an LSP + + +class PSLName(str): + """The name of the primary local reference to identify an ongoing payment gateway flow. + + Interface: Payment Gateway Controller -> Ref Doc -> Payment Gateway Controller + Payment Gateway Controller -> Remote Server -> Payment Gateway Controller + Payment Gateway Controller -> Calling Buisness Flow -> Payment Gateway Controller + + It is first returned by a call to initiate and should be stored on + the Ref Doc for later reference. + """ + + +class PaymentUrl(str): + """The payment url in case the gateway implements it. + + Interface: Payment Gateway Controller -> Ref Doc + + It is rendered from the integration log reference and the URL of the current site. + """ diff --git a/payments/utils/__init__.py b/payments/utils/__init__.py index fb540bd5..03da563c 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -2,7 +2,8 @@ before_install, create_payment_gateway, delete_custom_fields, - get_payment_gateway_controller, + get_payment_controller, make_custom_fields, erpnext_app_import_guard, + PAYMENT_SESSION_REF_KEY, ) diff --git a/payments/utils/utils.py b/payments/utils/utils.py index e56a89c2..2121167e 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -1,11 +1,25 @@ import click import frappe + from frappe import _ from contextlib import contextmanager from frappe.custom.doctype.custom_field.custom_field import create_custom_fields +from payments.types import PSLName + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from payments.controllers import PaymentController + from payments.payments.doctype.payment_session_log.payment_session_log import ( + PaymentSessionLog, + ) + +# Key used to identify the integration request on the frappe/erpnext side across its lifecycle +PAYMENT_SESSION_REF_KEY = "s" + -def get_payment_gateway_controller(payment_gateway): +def get_payment_controller(payment_gateway: str) -> "PaymentController": """Return payment gateway controller""" gateway = frappe.get_doc("Payment Gateway", payment_gateway) if gateway.gateway_controller is None: @@ -24,7 +38,7 @@ def get_payment_gateway_controller(payment_gateway): def get_checkout_url(**kwargs): try: if kwargs.get("payment_gateway"): - doc = frappe.get_doc("{} Settings".format(kwargs.get("payment_gateway"))) + doc = get_payment_controller(kwargs.get("payment_gateway")) return doc.get_payment_url(**kwargs) else: raise Exception @@ -149,7 +163,18 @@ def make_custom_fields(): "reqd": 1, "insert_after": "disabled", } - ] + ], + "Payment Request": [ + { + "fieldname": "payment_session_log", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Session Log", + "options": "Payment Session Log", + "read_only": 1, + "insert_after": "payment_url", + } + ], } create_custom_fields(custom_fields) @@ -175,6 +200,8 @@ def delete_custom_fields(): for fieldname in fieldnames: frappe.db.delete("Custom Field", {"name": "Web Form-" + fieldname}) + frappe.db.delete("Custom Field", {"name": "Payment Request-payment_session_log"}) + frappe.clear_cache(doctype="Web Form") diff --git a/payments/www/__init__.py b/payments/www/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/www/pay.css b/payments/www/pay.css new file mode 100644 index 00000000..a71bfe79 --- /dev/null +++ b/payments/www/pay.css @@ -0,0 +1,8 @@ +.page-card { + min-width: 30%; +} + +.btn svg { + width: 20px; + height: 20px; +} \ No newline at end of file diff --git a/payments/www/pay.html b/payments/www/pay.html new file mode 100644 index 00000000..99ddc8e1 --- /dev/null +++ b/payments/www/pay.html @@ -0,0 +1,73 @@ +{% extends "templates/base.html" %} + + +{% block title %}{{ title or _("Payment") }}{% endblock %} +{% block navbar %}{% endblock %} + +{%- block head_include %} + +{% if render_widget %} + {{ gateway_css }} +{% endif %} +{% endblock -%} + +{%- block content -%} +
+
+ + {{ tx_data.reference_docname }} +
+
+ {% block reference_body %} + {% set payer = tx_data.payer_contact %} +

+ {{ _("Amount") }}: {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}
+ {{ _("Document") }}: {{ tx_data.reference_doctype }}
+ {{ _("Customer") }}: {{ payer.get("full_name") }}
+

+ {% endblock %} +
+

+ + +

+ {% if render_widget %} + + {{ gateway_wrapper }} + {% endif %} + {% if render_buttons %} + + {% set primary_button = payment_buttons[0] %} + {% set secondary_buttons = payment_buttons[1:] %} +
+
+ {% for secondary_button in secondary_buttons %} +
+ {% endfor %} +
+ {% endif %} +
+
+{% endblock %} + +{% block base_scripts %} +{% if render_widget %} + {{ gateway_js}} +{% endif %} + + +{{ include_script('website-core.bundle.js') }} +{% endblock %} + +{% set web_include_js=[] %} diff --git a/payments/www/pay.js b/payments/www/pay.js new file mode 100644 index 00000000..8fff8b88 --- /dev/null +++ b/payments/www/pay.js @@ -0,0 +1,66 @@ +frappe.ready(function() { + + // Focus the first button + // document.getElementById("primary-button").focus(); + + // Get all button elements + const buttons = Array.from(document.getElementsByClassName('btn-pay')); + + // Get the error section + // const errors = document.getElementById("errors"); + + // Get the payment session log name + const urlParams = new URLSearchParams(window.location.search); + const pslName = urlParams.get('s'); + + // Loop through each button and add the onclick event listener + buttons.forEach((button) => { + // Get the data-button attribute value + const buttonData = button.getAttribute('data-button'); + + button.addEventListener('click', () => { + + // Make the Frappe call + frappe.call({ + method: "payments.payments.doctype.payment_session_log.payment_session_log.select_button", + args: { + pslName: pslName, + buttonName: buttonData, + }, + error_msg: "#select-button-errors", + callback: (r) => { + if (r.message.reload) { + window.location.reload(); + } + } + }); + }); + }); +}); + +$(document).on("payload-processed", function (e, r) { + if (r.message.status_changed_to) { + const status = r.message.status_changed_to; + const color = r.message.indicator_color; + const pill = $("#status"); + pill.html(status); + pill.addClass(color) + $("#status-wrapper").toggle(true) + const indicator = $("#refdoc-indicator"); + indicator.removeClass(function(_, className) { + return className.match(/blue|red/g).join(" "); + }); + indicator.addClass(color); + } + if (r.message.message) { + $("#message").html(r.message.message).toggle(true) + $("#message-wrapper").toggle(true) + } + if (r.message.action) { + const cta = $("#action"); + cta.html(r.message.action.label) + cta.attr("href", r.message.action.href) + cta.toggle(true) + cta.focus() + } +}) diff --git a/payments/www/pay.py b/payments/www/pay.py new file mode 100644 index 00000000..ae7894b4 --- /dev/null +++ b/payments/www/pay.py @@ -0,0 +1,108 @@ +import json + +import frappe +from frappe import _ +from frappe.utils.file_manager import get_file_path +from payments.utils import PAYMENT_SESSION_REF_KEY +from payments.controllers import PaymentController +from payments.types import Proceeded, TxData, RemoteServerInitiationPayload + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from payments.payments.doctype.payment_session_log.payment_session_log import PaymentSessionLog + from payments.payments.doctype.payment_button.payment_button import PaymentButton + +no_cache = 1 + + +def get_psl() -> "PaymentSessionLog": + try: + name = frappe.form_dict[PAYMENT_SESSION_REF_KEY] + psl: PaymentSessionLog = frappe.get_doc("Payment Session Log", name) + return psl + except (KeyError, frappe.exceptions.DoesNotExistError): + frappe.redirect_to_message( + _("Invalid Payment Link"), + _("This payment link is invalid!"), + http_status_code=400, + indicator_color="red", + ) + raise frappe.Redirect + + +default_icon = """ + + + +""" + + +def load_icon(icon_file): + return frappe.read_file(get_file_path(icon_file)) if icon_file else default_icon + + +def get_context(context): + + # always + + psl: PaymentSessionLog = get_psl() + state = psl.load_state() + context.tx_data: TxData = state.tx_data + + if not psl.button and psl.status not in ["Paid", "Authorized", "Processing", "Error"]: + context.render_widget = False + context.render_buttons = True + filters = {"enabled": True} + + # gateway was preselected; e.g. on the backend + if psl.gateway: + filters.update(json.loads(psl.gateway)) + + buttons = frappe.get_list( + "Payment Button", + fields=["name", "icon", "label"], + filters=filters, + ) + + context.payment_buttons = [ + (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) + for entry in frappe.get_list( + "Payment Button", + fields=["name", "icon", "label"], + filters=filters, + ) + ] + + # only when button has already been selected + # keep in sync with payment_controller.py + elif psl.status in ["Paid", "Authorized", "Processing", "Error", "Error - RefDoc"]: + context.render_widget = False + context.render_buttons = False + context.status = psl.status + match psl.status: + case "Paid": + context.indicator_color = "green" + case "Authorized": + context.indicator_color = "green" + case "Processing": + context.indicator_color = "yellow" + case "Error": + context.indicator_color = "red" + case "Error - RefDoc": + context.indicator_color = "red" + + else: + context.render_widget = True + context.render_buttons = False + + tx_update = {} # TODO: implement that the user may change some values + proceeded: Proceeded = PaymentController.proceed(psl.name, tx_update) + + # Display + payload: RemoteServerInitiationPayload = proceeded.payload + button: PaymentButton = psl.get_button() + css, js, wrapper = button.get_assets(payload) + context.gateway_css = css + context.gateway_js = js + context.gateway_wrapper = wrapper From 91cce3236722b3b04bc74a17c40a80e911bc5375 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 3 May 2024 16:05:20 +0200 Subject: [PATCH 02/31] docs: explain architecture --- ARCHITECTURE.md | 138 ++++++++++++++++++++++++++++++++++++++++ README.md | 22 +++---- overview.excalidraw.svg | 21 ++++++ 3 files changed, 170 insertions(+), 11 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 overview.excalidraw.svg diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..cecbae69 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,138 @@ +# Architecture + +The Payment app provides an abstract _PaymentController_ and specific implementations for a growing number of gateways. +These implementations are located inside the _Payment Gateways_ module. + +Inside the _Payment_ module, an additional _Payment Gateway_ DocType serves as the link target to a reference DocType (RefDoc, see below) for the respective payment gateway controller and settings. For example, the _Payment Request_ DocType links to this _Payment Gateway_ in order to implement its payment flow. + +Furthermore, upon installation, the app adds custom fields to the Web Form for facilitating web form based payments as well as a reference to the _Payment Session Log_ to the _Payment Request_ and removes them upon uninstallation. + +## Relation between RefDoc and Payment Gateway Controller + +The reference document implements the surrounding business logic, links to the _Payment Gateway_, and calls out — typically on submit — to the specific _PaymentController_ for initiating and handling the transaction. + +After the transaction has been handled, the reference document may do post-processing in business logic via one of the available hook methods on the result and remit a specific payload (e.g., redirect link / success message) to the _PaymentController_. + +During the entire lifecycle of a payment, state is maintained on the _Payment Session Log_ DocType. It allows persisting free-form data and serves as a central log for interaction with remote systems. + +### TXData, TX Reference and Correlation ID + +The data is passed from the RefDoc to the _PaymentController_ via a standardized data structure called _TXData_. + +Then, all transaction lifecycle state is stored into a _Payment Session Log_. + +The _Name_ of the _Payment Session Log_ will be the system's unique transaction reference to identify a payment transaction across its lifecycle and needs to be always passed around between the server, the client and remote systems. It may be typically stored in a gateway's request metadata in such a way that it is always returned by the remote server in order to reliably identify the transaction. + +A payment gateway's _Correlation ID_, if available, is set as the _Payment Session Log_'s `correlation_id`. If a gateway uses it for fault-tolerant deduplication, a controller should send this ID to the remote server in any request throughout the remainder of a TX lifecycle. + +If the remote server only returns a _Correlation ID_ but is unable to carry the _Payment Session Log_ name, then implementations can work around and recover the _Payment Session Log_ name, which is required by the controller methods, by filtering payment session logs on `{"correlation_id": correlation_id}`. + +### RefDoc Flow + +1. Call the _PaymentController_'s `initiate` `staticmethod` with with `tx_data` and eventually any pre-selected _Payment Gateway_, store the returning _Payment Session Log_ for further reference. +2. If called with a pre-selected _Payment Gateway_, use the returned `controller` from the previous step to call its `is_user_flow_initiation_delegated` method with the name of the _Payment Session Log_. If returning `True`, it means that the controller takes over the flow, if `False` the business logic on the RefDoc is in charge of the next steps. +4. If the user flow initiation is not delegated (to the controller): initiate/continue user flow, e.g., via Email, SMS, WhatsApp, Phone Call, etc. +5. post-process the payment status change via `on_payment_{mandate_acquisition,mandated_charge,charge}_processed` with a two-fold goal: + - Continue business logic in the backend + - Optional: return business-logic-specific `{"message": _("..."), "action": {"href": "...", "label": _("...")}}` to the controller, where: + - `message` is shown to the used + - `action` is rendered into the call to action after completing the payment + - If nothing is returned, the gateway's (if implemented) or app's standard is used + +### PaymentController Flow + +The _PaymentController_ flow has knowledge of the following flow variants: + +- Charge: a single payment +- Mandate Acquisition: acquire a mandate for present or future mandated charges +- Mandated Charge: a single payment that requires little or no user interaction thanks to a mandate + +A mandate represents some sort of pre-authorization for a future (mandated) charge. It can be used in diverse use cases such as: + +- Subscription +- SEPA Mandate +- Preauthorized Charge ("hotel booking") +- Tokenized Charge ("one-click checkout") + +Taking the flow variants into account, the high level control flow looks like this: + +1. Eventually throw on `initiate` if `validate_tx_data` throws, if there's an issue. +2. Wait for the user GO signal (e.g., via link, call, SMS, click), then `proceed`. We delay remote interactions as much as possible in order to: + - Initialize timeouts as late as possible + - Offer the customer choices until the last possible moment (among others: mandate vs charge) + - The controller can change the _TX Data_ with the user input +3. Based on this most recent _TX Data_: decide whether to initiate a Charge, a Mandate Acquisition, or a Mandated Charge. Mandate Acquisition is typically implemented as a pre-processing step of a Mandated Charge. +4. Initiate the flow via the dedicated method `_initiate_*` method and the updated _TX Data_. +6. Manage the actual payment capture along the user flow in collaboration with the payment gateway. +7. Process the result via the dedicated method `_process_response_for_{charge,mandated_charge,mandate_acquisition}`: + - Validate the response payload via `_validate_response_payload`, for example, check the integrity of the message against a pre-shared key. +8. Invoke the RefDoc's `on_payment_{mandate_acquisition,mandated_charge,charge}_processed()` method and manage the finalization user flow (e.g., via message and redirect). + +### Schematic Overview + +![Schematic Overview](./overview.excalidraw.svg "Schematic Overview") + +### Sequence Diagram + +```mermaid +sequenceDiagram + participant RefDoc + participant PaymentController + actor Payer + actor Gateway + autonumber + + rect rgb(200, 150, 255) + RefDoc->>+PaymentController: initiate(txdata, payment_gateway) + Note over PaymentController: Status - "Created" + Note over RefDoc, Gateway: Payer is instructed to proceed + Payer ->> PaymentController: proceed(pslname, updated_txdata) + Note over PaymentController: Status - "Initiated" + PaymentController->>+Gateway: _initiate_*() + alt IPN + Gateway->>-PaymentController: process_reponse(pslname, payload) + else ClientFlow + Gateway-->>Payer: + Payer->>PaymentController: process_reponse(pslname, payload) + end + end + rect rgb(70, 200, 255) + PaymentController -->> PaymentController: _validate_response() + PaymentController -->> PaymentController: _process_response_for_*() + opt RefDoc implements hook + PaymentController ->> RefDoc: on_payment_*_processed(flags, state) + RefDoc-->>PaymentController: return_value + end + PaymentController -->> PaymentController: persist status + + Note over PaymentController: Status - "Authorized|Processing|Paid|Failed|Error|Error - RefDoc" + PaymentController -->- Payer: return data to caller for rendering + end +``` +> **Notes:** +> +> - A server-to-server response from the gateway and a signed payload via the client flow may occur in parallel. +> - Thus, the blue area is expected to be **idempotent** +> - A processing lock only ensures that no parallel processing would lead to a race + +### And my Payment URL? + +The payment URL is a well-known URL with a `s` query parameter and the page at that URL can be used for capturing the user's GO signal to the Payment controller flow. + +It is kept short, tidy, and gateway-agnostic in order to impress succinct trustworthiness on the user. + +Format: `https://my.site.tld/pay?s=`. + +## Other Folders + +All general utils are stored in the [utils](payments/utils) directory. The utils are written in [utils.py](payments/utils/utils.py) and then imported into the [`__init__.py`](payments/utils/__init__.py) file for easier importing/namespacing. + +The [overrides](payments/overrides) directory has all the overrides for overriding standard Frappe code. Currently, it overrides the WebForm DocType controller as well as a WebForm whitelisted method. + +The [templates](payments/templates) directory has all the payment gateways' custom checkout pages. + +The file [`payments/types.py`](`payments/types.py) define relevant types and dataclasses for ease of developing new integrations with an IDE. +The relevant exceptions of this app can be found in [`payments/exceptions.py`](payments/exceptions.py). + +The _Payment Controller_ base class is implemented in [`payments/controllers/payment_controller.py`](payments/controllers/payment_controller.py) and a unified checkout page is implemented in [`payments/www/pay.js`](payments/www/pay.js), [`payments/www/pay.css`](payments/www/pay.css), [`payments/www/pay.html`](payments/www/pay.html) and [`payments/www/pay.py`](payments/www/pay.py), respectively. + diff --git a/README.md b/README.md index 7772261f..6ed332e6 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,21 @@ A payments app for frappe. ``` $ bench --site install-app payments ``` + +## Supported Payment Gateways -## App Structure & Details -App has 2 modules - Payments and Payment Gateways. +- Razorpay +- Stripe +- Braintree +- Paypal +- PayTM +- Mpesa +- GoCardless -Payment Module contains the Payment Gateway DocType which creates links for the payment gateways and Payment Gateways Module contain all the Payment Gateway (Razorpay, Stripe, Braintree, Paypal, PayTM) DocTypes. +## Architecture -App adds custom fields to Web Form for facilitating payments upon installation and removes them upon uninstallation. +see [Architecture Document](./ARCHITECTURE.md) -All general utils are stored in [utils](payments/utils) directory. The utils are written in [utils.py](payments/utils/utils.py) and then imported into the [`__init__.py`](payments/utils/__init__.py) file for easier importing/namespacing. - -[overrides](payments/overrides) directory has all the overrides for overriding standard frappe code. Currently it overrides WebForm DocType controller as well as a WebForm whitelisted method. - -[templates](payments/templates) directory has all the payment gateways' custom checkout pages. - -# ## License MIT ([license.txt](license.txt)) diff --git a/overview.excalidraw.svg b/overview.excalidraw.svg new file mode 100644 index 00000000..cb0e19e7 --- /dev/null +++ b/overview.excalidraw.svg @@ -0,0 +1,21 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daVPjSpb9Xr/CUf2lO6JR5768mJhcdPZcdTAwMWRcZph1mCBkW8ZcdTAwMDZjXHUwMDFiL4DpeP99bppcdTAwMDJtKVuAXHUwMDA08qum3sNV1paS8py75r3//lEq/Vx1MDAxY4573s8/Sj+9p5rbbtX77uPPf5rvXHUwMDFmvP6g1e3AJjL596A76tcmezaHw97gj3/9yz/CqXXvXo7y2t6d11x1MDAxOVx1MDAwZWC//4V/l0r/nvxcdTAwMGVcXKfv1YZu57rtTVx1MDAwZZhsXG5cXEqw6Ld73c7kslhjQZDUSr/t0erUvSdzTlx1MDAxN/kna1xyVmBcdTAwMTBDr1x1MDAwZVx1MDAxYlx1MDAxYW574PlbzFc/y62lwVx1MDAxZENef72mT5ZHjV5fnt74hzda7fbRcNyejHXQhfvzt1xyhv3urXfaqlx1MDAwZpuwlUW+fzuq7lx1MDAwZZpe4LB+d3Td7HhcdTAwMDPzUPDbt92eW2tccsfmO4Tevn15Mn+U/G/MXHJS5l/MXHUwMDFjsUBF5OLL3Xa3by7+N6wlrlx1MDAxMf/qVbd2e1xyQ+jU3/ZcdTAwMTn23c6g5/bhRfn7Pf66LSr9aze91nVzaIaItf/YXHUwMDA33uThUsqExFxmk7dccuYyvc365OX/n/9E++6dt2mO6Iza7eBT6dR/PZXXSeJPXHUwMDEz+uubP/37MPuvRqdXcIqFptnQe/JvLvD6XHUwMDE311x1MDAxZo96y/eouiDLrYej8fHa6enzz7f9/vz1N3/4o17dfZlNXHUwMDE47lYySpUgWL1tb7c6t9F7a3drt/5cdTAwMDT8XHUwMDExuJFcdTAwMThcdTAwMWNC41xmIFx1MDAwMSOehFx1MDAwNCVcdTAwMTDmiFxualx1MDAwMVx1MDAwMk5cdTAwMGZcdTAwMDT7kyg4XHUwMDEwtHJcdTAwMTTXmjNF4e8xWODcYEFcdTAwMDRyiEJII0pcdKKY8DhKNIpiREosOaEyXHUwMDAzjIQ2xMCQ5Xz1R9XtXGaPWs9cdTAwMTM8itC3a+5dqz1cdTAwMGW9xslEhqd46DVWurXLzt+ro0HLjL3U7l63av/4XHUwMDE52nOx3bo20/xnXHLuwOuHXHUwMDEwMGyBXFx522HY7flba3BtXHUwMDE3ztrfTMPl3X7rutVx25XUQ4OH4228kZ5D+FTsTlx1MDAxN2WBR1x1MDAxNlx1MDAwMTBBSFKshPTfio9gklx1MDAxZcG7cnn3XHUwMDFhy6dcdTAwMDda7bOBXFyr7a5cdTAwMWbsXHUwMDE0XHUwMDFjwZL6wHnBLHJQ4Fx1MDAwN8tEXGKThvZcdTAwMTj7OISxXHUwMDE2NtAyXHUwMDE1XHUwMDFlQFx1MDAxNMOYaklcdGhcdTAwMWb+U5pcdTAwMDdBN+iL+/OVXHUwMDFiV3ebi6RDUf9B7q28S9BxXHUwMDAyz4ZcdTAwMDexkYugk8mCXHUwMDBlS1x0XHUwMDFhn1x1MDAxNSY0PUzsT6LYMMGCXHUwMDEyh2uYeiBpXGJjOFwi6VxiT4uaz1xuPiy0ozBBXFwyzTFcdTAwMGWIsjdcYjFcdTAwMWXFjMRwXGKISvF5yHxY7r17+n5O7i3D5n633Vx1MDAwZVxus+wk3Vxmqo9KOttgMpRtjJDot29mXHUwMDFhMCUhgenqQ5alh2z3fGntabSwfF33Rlx1MDAxN5UrUnvaev6QbkpcIt8nXHUwMDFklYVqSlx1MDAxY5h4RPGX38KH5ORcdTAwMDRcbjn+RvhcdTAwMWQlk1x1MDAwMGI98+dcdTAwMTOqqvTP7VtwXFw6gauTXHUwMDAwqb7KOaakYFx1MDAwMlxid67k3O7NTne797i8UmvxpcZBY3GxU79cdMi5f9pP+3Jw+/m8ukZcdTAwMGZ3d/DtLW/vb4zocFx1MDAxMLnK6/Xdfr/7mPa8t2unm3S4U23oo03ZrO4/37mHJN15Z8tlxrlAuVx1MDAxYqBcZifKZcypxppcdTAwMTOMLCjn79Bfra+u0ChnTDhCMSpcdTAwMTWgRVx1MDAxMoqjKOdfhXIshIO4VnBccm43R0lMLFx1MDAwMzMrqSlccqhcXF8vl1x1MDAxOVdIflYuXHUwMDEzXHUwMDE0+naKXFze7LSGLVx1MDAxOEGp7I7vQk8yO+k8Q1xcRaVz8pDeJ6NfqMOmU1x1MDAwM0UkgZcrrlx1MDAxNNdW7Ir02J3OnIXErlx1MDAxMDIkocPQXHUwMDA1VDhcZpRcXKE1wVrRXHUwMDAw/WVcdTAwMGVdhVx1MDAxZI440mDdXHUwMDAwzXLu348vr1x1MDAxMVxmllx1MDAxMFxuTK84Jlwi6PF9XHUwMDAxM+FMKeBcIv82Mlx1MDAxNNdktrieJaF0cFanhfhg6PaHSzAjW53r8MB+RVx1MDAxNtJcdTAwMDDOPFxut1x1MDAxN5wnXHUwMDEzmqiNzLhcdTAwMTeQg4h59lx1MDAxYyNFzTSQOnazXqc+e1x1MDAxMPq6VmW8S0fjXHUwMDE1cnjvNVx1MDAxN8r1013LIIiDmKKcXG5cdTAwMDWmnNC+6I6MiipcdTAwMDTKM4eduVx1MDAwNOMpPqq2O1x1MDAxOC537+5aQ3ja5W6rM4w+1cnjWzS00PTc2CuHu1xubovyR8+cMayJ+X8r+WCa/OPt7//3T+veyXN8sjU2u/3z/VxifiZRX73l3nU7dVx1MDAxYvmJXHUwMDAwXCJixomCKyNMbTEkmZ78pr/5QpKfXG5ojOZcYoZYyIOApkSUPmuPMOTo4I/NPJEx3zkmXGJcdTAwMWJrXHUwMDEyZaCsfIjg7Fxu/ix75Oa6fLtdbVx1MDAxMLahT043Tlx1MDAwNqqO6tdp7YY1tE7Wu4dcdTAwMGbrT+MncTRqXHUwMDFjn7tPT1x1MDAxOdgj98/y8GhA1tu7W5Xj0V6TPd1cdTAwMWOeZ3DeT9hPrzBPllwiX1x1MDAxM2hcdTAwMGJcdTAwMDaUo3QhXHUwMDE5R5RzZKNcdTAwMGKVni7sU6LYdKGIw1x1MDAxOFx1MDAwMllEsJSSht2PjFx1MDAxYnL/XHUwMDEy8tDYXHUwMDExWGt4XHLixdCJc4eM2zlcdTAwMTRcdTAwMGXRXHUwMDA06e+0czKJu6W3c46a3VG7XrrsNN1cdTAwMDdcdTAwMGY+7tyOXHUwMDE50P981N65a9XrQe9fxOSZIVx1MDAwMqMmz6/RTcZcdTAwMTZcdTAwMWZZpmG3ZOkvMYI5JJW/h1x1MDAwZmedXHUwMDFlzvjhaUe01tlcdTAwMWTZa3SGN9Xx9fnhbbHhjIOxRnOIIDnKe2xcdTAwMTHwOlx1MDAxZVfDIN8xJTpcdTAwMTdcdTAwMDPmw/7GvFx1MDAwNPEsveHk4um6UltcdTAwMDBcdH1w2uiNXHUwMDFm1+VFtZtWXHUwMDBlP5f799XjvVx1MDAxZHfzqlW+WcbH9ZpO6W+cet5cdTAwMWK8ufd4Vju4Ol7DgzPR3NtE7uJ8yXdCXHUwMDEzXSGCMCk0XGbCwlx1MDAwN4vp+cD+5orNXHUwMDA3XHUwMDEyaYdQhJQkYHVG5bvgOjKUXGadXHUwMDFmRnegXHUwMDFhbGFGdYJEt3gupVx1MDAxNGCpI5VcdTAwMDFZzItEf3NcdTAwMTMuN93+tZeTIJ8hzVx1MDAxMn2X0UFl5rpUKPr1m1x1MDAwMMdcZnMkXHUwMDE4sVx08KX0gJ1O7sVcdTAwMDQsXHUwMDAywHKJKFx1MDAwM8pcImBcdTAwMWGHxTln3OFEXHUwMDEwUNi1UDQ/9DKEXHUwMDFkqiQo/4JcdI219NVcYt+WJ8KhsFx1MDAxMThGYlx1MDAxNZTvb5JfcSQp2Fx1MDAxNXlI/tws++HVwWBn86rfpui+PV66LovzlYV8JV7gPabyi850Sb76XHUwMDFikVx1MDAwM2+GMY2UppJcbsz9qG/p1XFJqMNcdTAwMDSRSnNcdTAwMGUqXHUwMDFkQ0rFnmwqP2nzrHO0srHJXHUwMDFifFx1MDAxM/WPatu393uVZ9ugXHUwMDE2kEMxTFx1MDAxYqYkXHUwMDEzXHUwMDFjiMD3XHUwMDE0vVxyXG5cdTAwMGJHUkpMpFppxEAsxFx1MDAwNjVXblJcIlx1MDAxZEa04CZLVEmNRfBwwVx1MDAxY1x1MDAwNjjRmiOKXHUwMDA10nrW6ZLh+XKxXGIw/dP9XGJ+vlfFmlx1MDAxMihcdTAwMDaq0vC+bKnKy+lcdNuOvGJcdTAwMTM26DhAyVxibFx1MDAxY42A73g47ZGrKanKn7W/qKO5hnePQbdcdTAwMDWQiHRcdTAwMWGW5oTD7CC/kYa1181JqZqhYUSVquA4MtKj9Fx1MDAxNDWKMkSwVMqGypX0qJzuVC4kKuGpOIBGRVx1MDAxNVx1MDAwM3woXHUwMDE2iYlQ5DDgWq2JkFx1MDAwMpH8IFxumqwjOFaCYlx1MDAxMGaAO/9VvEHUJGwxSbimIKlcdTAwMTHnsYQtIFfNJJL+MOdBi3paXHUwMDFleSv7y4PFJXVzsr300HVcdTAwMGZ2cl6Ak5tcdTAwMTZcdTAwMDVcblx1MDAwYihR8Fx1MDAwNjRcYmfJMFxyye9fXHUwMDFhi3JcdTAwMTBcdTAwMDPNXXLj7Fx1MDAxMlLHXHUwMDE1llRa1Nb246i5evH4PGyPXHUwMDFmd8tLfO+80bWrdlxmLlxi05dyhbD5kLExYdC0KFx1MDAwNVx1MDAwNCBcdTAwMDVEXHUwMDAwqoKMXHJqnrQogIpWXHUwMDE4g1x1MDAxYVx1MDAwYrdcdTAwMGJKXHUwMDA3XHRcdTAwMWW9wEhcYtVIiVnnS4bn5IQxZGakR6nod6+MbZLAKaiJNsJeTU/YduhcdTAwMTWasLFcdTAwMTTc0cxkuEv4pbn/XGImapSUTn55OpQ4ZuGjYKBNXHUwMDAxwINpc1NcdTAwMTQpXHUwMDBlskXBXFz8jfSoc2+Qk1wiNUPHiCpSoYG8T5OaklFCdGK6u2DMJFx1MDAxMlx1MDAwNXzMPjLX0iNzOrlcdTAwMTdcdTAwMTOZ8Fx1MDAxM8JcIlFTXHUwMDE2oXzWpFx1MDAwMcs1lK3ie7imppBcdTAwMTjGXHUwMDEwYFj7UCiCipRXTsYs1WvP69fLJ0/06v6WMHd/XHUwMDAzr4xcdTAwMTcqaUNB9fZJu78jr5euyue4556s3lx1MDAxZa+yT1xy9+W8q1vtxlx1MDAxM188ZfWnweCqfNha6vbq6c6bg6r4IblNaHJcblx0Y1xmKyqELYVkPT1B2F9dwVx0QipcdTAwMDfrpFx1MDAxY1x1MDAxMsryo4tcdTAwMTRJI9xCXHUwMDE2glx1MDAxMCRcdTAwMDTKYJ3nvFxi7lxyd5B/qshcZuFcdTAwMTaV4WZMuaaI4IBhXHUwMDFmTY6XYKkxTWyK9kZ6tD5W2NnWwvPqlSe3j/vsZqPPV1HB0Spk2EPJXHUwMDAysyj7XGZcdTAwMTFLxruKuyRcdTAwMTlGoP8rmcEy0swzRLKXh7PEd71xurPLK5ucnS5cZjZ38cnT3jZLK2aX3fH9jWRdgbv9ylx1MDAxNj0uN0aoloH4btRcdTAwMWa2XHUwMDA3hzfqXHUwMDFhbTbQwVx1MDAwM1m54qOb+Vx1MDAxMt862VNqXFw6lFwibOODzfR8YH9zXHUwMDA151x1MDAwM8VcdTAwMWOqkZGKXGJEOfZcdTAwMTWYXHUwMDE3V2mehjfWxCFYSyW5XHUwMDA2XHUwMDAyXHUwMDEwttVtcVx1MDAwMa5Nhlxi+Z3E91syxu6LvDTpn7kmi8xcdTAwMTBsiclcIm/jyylrhCcvSFx1MDAxN5Rjoe1Jn1vvgPBUui8mhIlWjjYxSKJcdENKhn1njFCHMMUm60GFXHUwMDEy+anjVFBcdTAwMDcxqc1aIK5hWHE0M+ZIjbRAwvwvSHy5m1x1MDAxMnBcdTAwMWGueC7lxnKLdiytXHLun9vLz0TUbjfa9z12c7Y9Lla041x1MDAxZIFcdTAwMDWYRZiYuDLDIFx1MDAxZmQ81oGVg1x1MDAxNIVBY2zCXHUwMDBiRMXjXG6pglx1MDAxZNN5plx1MDAxNIrAYCGEJEZCYY7Mcr74qEho/Vx1MDAxMZiisVHNU7RD61x1MDAxMJqEXHUwMDBlXHUwMDFlzZAjucmSXHUwMDEzhE2iXHUwMDE3s86WXGLOydlisPRP9yP4+V6dK7E2XHUwMDE2SHtpcvRtObnb6enajrti0zVcdTAwMDethzLJKWBcdTAwMGJcdTAwMWV3YFHNxF+iXHUwMDExYIvAdoW4SZ+jkZF9d9xcdTAwMDNcdTAwMTMkXHUwMDE06Gq/k1x1MDAwMpZf4GOGvpFh4CNRqVxuLuGOZc5jrYDdbWGPnfQone77LSZKwVx1MDAxYXSQYExLzomiJIJSXGZsXG6zUGlcbkKJUZ6jkYQoiDVcdTAwMDFkLyhcXI7YvJymuFx1MDAxZVxibS1cdTAwMDRcdTAwMTMmkVx1MDAxMMdycVx04iDRSSalur5Qr7qS3kZ17+jibLx038Kdw53ecmVpTvUqk0VcIjgwv1x1MDAwNu1FXHUwMDExXCJYPO2VmPxuo90gXCJNhvdcdTAwMDeTSHa3T3i5Pb5+elq72u+c3PNm/6iVkFx1MDAxZsxcYjHLNDRcdTAwMTKgzFx0WyqudExcdTAwMDFcbk7NXHUwMDEyXHUwMDEyXHUwMDAyzD/falx1MDAxNVx1MDAwNTiZxb6EMYlBXHRhwaNcdTAwMTewXGLjms1OXCJJxOfkhHFoZqRaJWfjakyNXqFsrL2bnrXt2Cs2a1x1MDAwYlPuIDFcdTAwMWJcdTAwMTdcdTAwMTgwR2/Wx/JxXHSSwFBaM/9O/vLqVG75uDNcdTAwMTSN7PJxp1x1MDAwNp54ctVEJSlhxL6yaS89NFx1MDAwZp72KnfVXHUwMDA3ctfdLFdcdTAwMGWeNsX1WbPoK5uigSdC8rNsrEuTVay0PVx1MDAwNmJcdTAwMTZcXFFcXKzA08xcdTAwMDVKg62zPXKz2yrv19xBtSHW0Mb5XHUwMDE35GFMPe/BQ+3ioHd/jo/IWflxsUFFozvO4LzLq8uNu/WVR7z3cLZzuK9XK+3K6XxcdTAwMDWeXHUwMDAyJnwsRZ8qiTFcIra0kf30fGCfXHUwMDExXHUwMDA151x1MDAwM42dSeYy/KEmdybMXHUwMDBljORcdTAwMTl4UshRVEvzh1xuUGzjbFx1MDAxMY87SbBcdTAwMDKlyVx1MDAwMf99XHUwMDA0dTSwU7rsLNbuR61cdTAwMDF8XHJTO1x1MDAxZiE+Q7jNXG4+lawjzLT0SGLzXHUwMDFhrYzqp7TNq1lOXHUwMDBm5663Kq/Wxe7m+GRU2+63+6Nn5Fx1MDAxNVx1MDAxYs5cdTAwMDTTSJqoMWW/XcArxVx1MDAxNFx1MDAxMprMV03/heWlpYvOXvn+1qupllq/662dp85cdTAwMDDJS1x1MDAxMLuN9spGXHUwMDA1jZRcdTAwMTitPy2sLy5cdTAwMDJcdTAwMWSuZ3Dev0BmyZTaI1gyrSmmzCbhXHUwMDBm0lOCfUpcdTAwMTSdXHUwMDEylCN9W5yFXHUwMDBisVx1MDAxMpZnYolyZpnicVx1MDAwMW+aXHUwMDFmwKtcdTAwMTJZ5KHNi4Qv97s10yHHKuBLh96g1+1cZvJKM5kh56KSPjrW6SPNUOJTnqzBK1xm78y8Olx1MDAwYr5cdTAwMGbT4/u00+m5V1x1MDAxZHJ4s/5wqDzZvGn0io/vsMg3Xbu+WORjXHUwMDE2biuEXHUwMDAyPoXX1lxcSjMu58zErzWbtHf/WNne9Ta6q/1cdTAwMTba7e8/fEFcdTAwMGVoUSQqnVLcXHUwMDE3xFx0MVx0izYl+yg94uyPuOCIXHUwMDAzq1x1MDAxOFG4d841Ulx1MDAwNEUyvVx1MDAxOM01WVx1MDAxMzumUZFSXG6sdbMoNo1MxVx1MDAwNKaLwEz8Ru7tiJzyszXzlqczhMhcZnn6mrWZrywlLHlVhlDMLMW2XHUwMDAxu5JcdTAwMWXYo0av+dzf17uVwcrqxePu7Vx1MDAxZCp6y1x1MDAwMoJJWDlcdTAwMTY0Tyindo9TiVx1MDAwNOZzJjtcdTAwMTlcdTAwMWRcdTAwMWSdLF93RoPubnPBXVx1MDAxOVx1MDAxZC7i1e+usNleXHUwMDFmsNb6pnykjfPnU9mvdlx1MDAwZvD2fMnkKX4vrFx1MDAxNCdEIGRcdTAwMGI5XHUwMDFmp4eu/dVcdTAwMTVcdTAwMWO6nDqAXHUwMDEzpFx1MDAxOTGWvoiW2Mwvylx1MDAwNXJcdTAwMTWo2FTEYMiYremWP0rBqFx1MDAxNOh3NHJ/ybfLTs5yeIZcdTAwMDRKksNcdTAwMTmJ38REP8pcdTAwMTLdVCCAmGl5zG1cdTAwMDA+eUdgeqrzsaA9OOHGQ0akj6OXpFx1MDAxMexgwkG5XHUwMDE2yFTa08m9vlx1MDAxYUjVXHUwMDEw+oxcXKaRXGb2OKKRwyVcdTAwMDNcdTAwMWJIwmBcdTAwMTjjWsQsXmLaQuFM1jd/Yabf3THq11x1MDAxZTduOic7+1x1MDAxN/p+ba2zUWPFyvSbXHUwMDFlmCpcdTAwMDWz6pBJXHUwMDA242D1XHUwMDEws35CwZ/Abr/S6sKNWVxi+eBcdTAwMWGK6U60wKhM7yHGTaVcZpg8RDAp/Vx1MDAwNdKBUcVGMU/JfclcYppcdTAwMWNcdTAwMWXDjn++XHUwMDFmwc935+IlXHUwMDEyq2aKmmVpNpvmND2v2tFRaMVcYmulXHUwMDFjTE1cdTAwMGZ0zkyUPeysIEw4XHUwMDEyXHUwMDBiXHUwMDAxb1x1MDAwMbiVJJcy/iypYqJcdTAwMWNJNSio2uRAU2axfWx+XHUwMDBik6mrdFx1MDAwNsvQ5kVL6niPr1x1MDAwNVx1MDAxOS471/D70Vx1MDAxZJdcdTAwMWHtIFazjfNP11x1MDAxNaJaUorhZaQnseTeXCKTdqCMWOtAnaWH83RcdTAwMGZtQdUkZVx1MDAxNqQlqklMXHUwMDEwRypcdTAwMDSKXHUwMDEyV6ZTkE52X3xeTZIz1STi8F/d/7RcdTAwMTaYaVx1MDAxY1OTQFx1MDAxMGpTM1xuz9dK07PmXk3sXTSvt5c6293a4rB6ceRcdTAwMTVLT5q5qvOXqlx1MDAxMai2XHUwMDE4btuIQIxrM5nAqoVcdPUxvWi6MzSg8NhcdTAwMTU4wjGoXHTINFx1MDAwMMZcdTAwMWNxNt+LXHUwMDFlkiEz2Vx1MDAxYVx1MDAwM0tGelx1MDAxMUuOmzKlzIpcdTAwMTNmU4zO0zOpXHUwMDFkXHUwMDBlXHUwMDA1V4yEXHUwMDA02YSQxjyikLyU3FCOMitUqITHXHUwMDAz7yM/zUhcYodpU8RS6aTm0rYsicmqOMV+o/Wfd69cdTAwMTGS2sRF8yXq0VxmXHUwMDFkIapcdTAwMWWlXHUwMDFjYlaupORCXHUwMDFjXHUwMDE4ppXpJoFsXHUwMDE5XHUwMDExXHUwMDE36ZE93Vx1MDAxM19QXHUwMDFkSaPwXCJcdTAwMDchuVx1MDAwMyaQNqXYSZ5YXHUwMDBldoppXHUwMDA23q+pa8RcdTAwMDDaiFx1MDAxOZUt8ChelSBJNNU6n1x1MDAxME9uSlx1MDAxMFx1MDAxNqOlzVx1MDAwNVQ9qJ6OWGuxeb5wy46LpVx1MDAwNE3vt1x1MDAxNNY1tFx1MDAwMP7lXHUwMDEyXlx1MDAwM1x1MDAxNlx1MDAxNFtqi+O49pFKXHUwMDA3mu6IXHUwMDBlXHJcdTAwMDIjU1x1MDAwMVx1MDAxYmQy41hg8dfzXGZcdTAwMDX7cZuFnjFsZKTzTClcdTAwMTKKXHUwMDA1J0oyaWNGNz0z2md/wXVcdTAwMWUlpziDXHUwMDA001x1MDAwZeZamaxcdTAwMTZcdIp/fi72XHUwMDBmeoNAO/7dMkONv+VcdTAwMGLVnVx1MDAxOeLe5lxyylLT8drtVm9gzVjBMtGKXHUwMDAx/V1gXHUwMDE0zMPyXHUwMDAxXU1cdTAwMGbo6cv1i1x0aEai6z1U5OI+aElDe4x9XHUwMDAytDxsvWqLw8dSVlx1MDAxNKxcXJMll09Z0U9WXHUwMDA1z35cdTAwMTnmzIb1y8u9Ja/F7lx1MDAxYZ1cdTAwMDWuO/01dry091x1MDAwNaswp563Uq/23WGvcnO1ft6snFxmzy+qOylcdTAwMTer5KDjfUje4+SoOsYgYrAk1rSYWnp+sL+6gvNcdTAwMDNcdTAwMTeOQEpgkLiUxoI/3GFMgkFkmt7BI0o2jD7LXHUwMDFkoHcgrjUyyUkqddaqSURcdTAwMDS9MItqOfNcIu+PXHUwMDA3Xr902Zkko3j1vOpdzVx1MDAxMHVRMT9cdTAwMTlUfEhZlVx1MDAxM02uXGLMMVJJla/q6ZE7nTeLiVxcrlwiXHUwMDBitaag87MpbIFT+6GbKFx1MDAxYVx1MDAxOUVcdTAwMWN/X3OP7/RcIryjkFx1MDAxM1x1MDAxMtrwLGembTClXCJeXHUwMDFmUzlg5ZrOwVx1MDAxNFFcdHb+XHUwMDA3XHUwMDEzTt6TXHUwMDA2wyaJXHUwMDE2QmouQFx1MDAwZVxiXHUwMDFlXHUwMDFiXHUwMDEznu+OZFx1MDAxMlx1MDAwN3dfXGJU4fxcdTAwMTH8/IDVQVRinjzGlJuGvtrmR/DSk9P0hr3FJCdcdTAwMTZLlJ9W0umbXGZcdTAwMGaqOZ5cdTAwMTTVLJThMbNt0FWtvrm7s7mxd77TW9PD577gXCKtXCI/vVx1MDAxM2ro+u8yXHUwMDEwbvDm3uNZ7eDqeFxyXHUwMDBmzkRzb1x1MDAxM7mLXHUwMDE5nHfzaeNpQ1xmx88nJ1x1MDAwZlx1MDAwZuWVc9f1rtrzZXiQKXWmXHUwMDEwXCJMmVx1MDAwMsFcdTAwMTaGaKRnXGL7lCg4Q1xikmx4XGJOXHUwMDFkXHUwMDFkNDyKZXdwimFPnIWq81x1MDAxZrMjaHbMXHUwMDEwdV9sdkypXHUwMDBmR7iZnNZ00et3OFxmprJmMXFrymIrszCFYmlqc4ZxK7kjJKVcdTAwMTIxIbgk+TUxkMTBhGpcIlx1MDAxNJHc2rBcdTAwMTlcdTAwMDG9aG7KRpk04tAq3DfPI1xmlFx1MDAxM86/S1x1MDAwMfhOk2U61ErhVC9cdTAwMDbPXHTsbMqJ6TUgRbz6rDJVmIWW+KXXgPpgNljqcKxcdTAwMTlcdTAwMTVnmvHAb0tN3PlOXHUwMDA3S5rk5mchPr/90/1cYn4mXHUwMDExn6FJu6c0MTRKpOJU25f+Nt9cdTAwMTFcdTAwMTk9u1x1MDAxZpYrcplcdTAwMGWWtmD27d/Qzr2bKe/lkDPCXHUwMDAzqVx1MDAxYuZcdTAwMTBcdTAwMTlsipL1kkGL/Vx1MDAxMvDu/MrwXHUwMDAymjBz/7tcdTAwMTJjv4K9otBL2JIxqEPbMkV0yFx1MDAwZqHwO1x1MDAxMZtcdTAwMTjb0IlccjxcYoZcdTAwMTdi2s/bINtKXHUwMDBmWXZ78OiOcOWE1tfxuVfZv0OrjaJDlqpwmpfKsZbtpH+T1JxhhO0t0mTcXHUwMDAzgbhp4MN/p1qVnnPtlKrjUq3dqt1cdTAwMDKUS91OyS1cdTAwMWRcdTAwMWbuXFx2uv3SwFx1MDAxZJvvLn+OvcHlz8m2Tmnz5LBcdTAwMDRGRHuyx9txl1x1MDAxM+Oh1Vx1MDAxOXmwY6tTXHUwMDFhtMwkWOi5195CrenVbrujYYKZ0vZcdTAwMWHDKUbKsNtLslBCjyVqjnzTrWXaizU5XHUwMDEzXFxz0LGItto8d+mJpHq7ua7Xdlx1MDAxNiu7t7Q8XFx4Zut7g+2CXHUwMDEzXHTVYdFcdTAwMGY6V248QqTFlsGEhOtnxfqxXHUwMDExs1x1MDAxMk6E2o7MQ1x1MDAxMZCjQatWbt+OujtbO/KC1jb02t5aWufg7drpJlx1MDAxZO5UXHUwMDFi+mhTNqv7z3fuYVx1MDAxNkVAPuF0nHrev0BcdTAwMTaFSlxccqe51FQhbKOHTnp6sE+IYtNcdTAwMDMjalLuiyDFTIeuSGlcdTAwMTGFcq1cdTAwMTFEJuW+uDJZm8q6OMTWzUJrpEDv+I1cdTAwMTaHLFx1MDAwZW5LXHUwMDEzh+GwW+q9+Fxm82qxPkPGRfWG6SPLyptcdTAwMTl41LHit4hJSnmgeZaP3W567E7n42Jil1x1MDAxM+1IXCKV0pwwk4lcdTAwMTjCLmXSIYrDfP31OzdcdTAwMWNTh5lcdTAwMGV/1DSGRVhw5r9cdTAwMGJcdTAwMWbH0qzFZ29cdTAwMDNcbmRy/opLSGxWKORTRvtzXHUwMDFlXHUwMDAxXHUwMDE4lsAoOKkz9md2z5fWnkZcdTAwMGLL13VvdFG5XCK1p63nkD/zxSNcdTAwMTjop1x1MDAxOUyAQFx1MDAxMlx1MDAxZZzQXHUwMDE4aFRSRSmO3Wwq9+V04L9cckJZXHUwMDA2YVx1MDAxY5rIuFeF6bJgllx1MDAxYypcdTAwMTl/5PPkvUye0uYnNpn90/1cYn6+m+gwSyY6zqhcdTAwMTaKcJuS0ktPdNNcdTAwMTXETIiua15wlkQnpFx1MDAwZedIhJVcdTAwMTStXHUwMDFkJolAglxiXHJaXFx+RFx1MDAwN/ByqOlgXHUwMDBiXFxLiNaWamjMIabrLCdSKoEpj1c8XHUwMDE02nRcdTAwMDNcdTAwMTHkd8w1m8kxwVx1MDAxNWtcdTAwMThcdTAwMDObSSlcdTAwMThmjFx1MDAwN5dWvdLhx3juPcEjrlx1MDAxOVx1MDAwMu1cdTAwMTgpk+SvLaEjXGYyVlx1MDAwMttcdCm4KUM731x1MDAwYthcdTAwMTLnt/mJzeysSI+IxDQ0XHUwMDA285Ihe6z6Pj3nTTdei8p54eInXHUwMDEyIcf0QZfwglx1MDAxOFxintxITlx1MDAwNYbmd3+VKnT1eEBcdTAwMWFUXHUwMDEwykDsXHUwMDE3UH8rXHUwMDEyrVx1MDAwMUlIXHUwMDA0MKJcboxeaSGUj5Fa6iRe0yFcdTAwMTZMfdDUgFxcOcO2IWBcdTAwMDc2mKKfhFx1MDAwMtBcdTAwMDWf70A00+FVuYxEZvKsXHUwMDEzXHUwMDA0XHUwMDAwMTlBXHUwMDFjXHUwMDBi72TCpOC1SEzaYZJrrG2Zdv30LPh02+qP75/LO0ekeYRHj7dnJ62MNb/MTVxcXHUwMDFkycVlU9btfpb4LLTH402uMeLw6r9vxd9/XCLX5ud9XHUwMDE0XHUwMDEwxi/Pqj0yTa5WTZCWwK+Y2/LnXHUwMDA36TFb3u2uXHUwMDFj7ZZ3bpv6XvVP6J08PtFcdTAwMDXHrIpcdTAwMTQowVwiP2WFUDnpskjgXCJgXHUwMDFkS4tcdTAwMDdcdTAwMTlbYteKMEQxzlwieW5eXFzIXHUwMDE1mMyDUrfxWuBwUDIh3z8uO1x1MDAwYqWjUXVQ67d6psvR5N+r5cXXjlxy5t/lvueOhs1uXHUwMDFmrvnaweGyUyr9/fJnszv02qWBV1x1MDAxYvVhelxc/vyH2b9cdTAwMDKvuFx1MDAxM9+32/FewsSl1zgw7P+1Ue7CP4T3OcxcdTAwMTNcdTAwMTWJRC+SZlxmJJiNlIbpSel4XHUwMDEzN/Da3XnncHWjo+pXg4WHjIuD5OErXHUwMDBmK1x1MDAxMoE80K9QJEhMkVCaU1Ob7j9cdTAwMTlwc6pHkKzUXGLMXHUwMDEzc1Y5psxcdTAwMTSotPk/Ru/wf2h5vPiwdtPcKN/Wm4e9m1uxfFdwwDLCXHUwMDFjXCKxSSCW2qyTXHUwMDBlw1fn2NlcdTAwMWRhh4GhQVx1MDAxMlx1MDAxNFxuXHUwMDE2X8vPNFx1MDAxM4SJLNJZv1efXGJcdTAwMTRpm6FPnLqtYanR7ZdGJvrb6pS6/fokXGZ8XHQztO2OS95cdTAwMDNcZnrktkvD1p1cdTAwMDdy7sPrbj4q7D8zwlxm89J4cktcdTAwMWJcdTAwMDRmJMJU2uD9kFx1MDAxZd7q7Flccq7GXHUwMDE341q9+njaYaw32KlcdTAwMTZcdTAwMWPehFx1MDAxOTdTXGLThLDERLHMm7NbVtkyXHUwMDFkiTLFSlx1MDAxOU6WTlGSRVnSL8xTqyyK3d7e7tJcdTAwMDM7cVx1MDAxMdtw15bvq/vf3ZL5L9CsakqsXHUwMDE2TVwi8koqXHUwMDBisFx1MDAxZt8ht61vrujApsyhlCtOuVx1MDAxNEKEIM54flo36NJcdTAwMGUlimiimFx1MDAxMNK2jt5cIrlNhFx1MDAwZo6U3ym5OVVBXHUwMDFkMG/JfVxi+jLIwUNvMGpcdTAwMGYvO//eheG7194/S8uV9cU/c0ormyGjorI77Vx1MDAxOLPsXHUwMDFhiZLXzEqsiCTBXGZzXHUwMDFmz0/p8fxYZpt3j2q/fXXQ7i9ccnY6q1xu3Vx1MDAxN1x1MDAxY88s0Fx1MDAwNNdcdTAwMWOhp/RRySd/XFyH+y/H1pKZIFx1MDAxNctmRftcdTAwMTdK5fMtctxjdLmy8zSoPVXvn9Vp+bSAUi7YiCRcdTAwMWGVMqXniFxiTFxiXHUwMDFmXHUwMDE14/SosD+JgqPCWKdcdTAwMTiZMjmEU43CYk6raVx1MDAxNWM+jVx1MDAxMlx1MDAwMmdcdTAwMDf5JolmXHUwMDFhZoDNRo37vDlcdTAwMDIhw36rnozdztUvP+/Vf1xyhu5wNPjvy1x1MDAwZXz5UtQ18J0vXlx1MDAwZb16y0iJvMTgXGZcdFx1MDAxMFx1MDAxNYPZ3EFmXHUwMDA1+ZOIXHUwMDAw5lwiNlx1MDAwNp2FXHUwMDA3ntPzwHRTo5BcdTAwMTUlgFx1MDAxY2nUipXMYUyYJU1EKDWlleOnV21aWzeagqCEIT75XHUwMDBmx1x1MDAxN1uBbaIp2Cff1rvxO1x1MDAxM3VSNzI0te4kw1xcUmQqgkhcdTAwMWTMvX6tXHUwMDFhwWM3mypXZ7oqXHUwMDFjXHUwMDFhhNSWQv3xXHUwMDE0oblKx4m0S4zOV//4XHUwMDFmwc/3p1InOtVcdTAwMTXCXHUwMDEyISotynw1WPd9XHUwMDE2X013YVx1MDAxNJWvkFx1MDAxM21cdTAwMWaSI0PZXHUwMDFhhsRcYomDtc5hYP5Qf1x1MDAxZkJK3T3DLLGgVFxi4zJcdTAwMDZe4ibkXHUwMDEwL3Wpc+ckXHUwMDE4h1RcdTAwMThR4zahXHUwMDE0m84ucYaa84TBSFx1MDAxN493MlJSUJ7QxDRnTFxivFtl9S9UXHUwMDExTk9JO9vXg90nedDcXHUwMDAzQfdwdrS/c+ouXHUwMDE1PNFcdTAwMTmei4pwXHUwMDEy/cJIgDXbXHUwMDE58Vx1MDAxOZFcdTAwMDDGXHUwMDAxXHUwMDAyXFyQvzJn/dWi98HSwCVTPX7WXHUwMDAxXHUwMDBiNHREfFZkxFxmlCUzg9CSJvhYqoikZ4YjekbvXHUwMDE3XHUwMDFmnlx1MDAwZk+PN6737p/Pz9eed4rOXGZgU0aYIc+yfKCIWNyPRHNHIK6oJohcdTAwMTEpqaXBmVkoqLj6XHUwMDBmXHUwMDE5zFx1MDAwZlx1MDAxOSwoXHUwMDE23J9cYu2AXHUwMDE2ylx1MDAxMWHUNKyjs8khOF0mp4hNlHfSw9ToXHUwMDA0XHKImli0kVFTTU/50FxmcFx1MDAwNE3PXHUwMDExqNypjKW8XHUwMDFkl+nF8+79inbHXCLj8ETmXHUwMDFjQXWcXCLS6lx1MDAwZZ9uiEhcdTAwMDIuLz/IaCmVhYDCebBcYt88RCjaXHUwMDBiXHUwMDFi5d3Tepfs15e2nm8vXHUwMDBl1m4rK++KUDAlZCB3LadcYlx1MDAwNVx1MDAxNYlcdTAwMTXkTOFDLsFgYTZosHfY+tZnUWxoYIqoWbOJXHUwMDE1plowXHUwMDE1aVx1MDAwMizlV+GEKukwXHUwMDAxdClcdJLI3lx1MDAwNJjGUCONn9Is6P48aD5cdTAwMWOwmEzgXHUwMDBmVX5cYlx1MDAwNixU6NspXHUwMDAxi6MhXFz+j1LZXHUwMDFkm8GVjmD0MJlLO91AXeZMw1x1MDAxMTNcdTAwMTg/XHUwMDFhjkgzvoyCXHLBZrPRpFhcdTAwMDSqt1x1MDAxMNiqXHUwMDEy8/SYbtRcdTAwMWa2XHUwMDA3hzfqXHUwMDFhbTbQwVx1MDAwM1m54qObgmNcdTAwMWFUYlx1MDAxMTOW80uF5dpcIuBwsMLCL1x0R7BGXG7lJOFya0dBe9X6iLisR/XB4PHwcEyuym6xevymjlgsYOMgNFx1MDAxZEFcdTAwMTRcdTAwMDGJaKqDXHUwMDA39vpkyOKxws62XHUwMDE2nlevPLl93Gc3XHUwMDFifb6KXHUwMDEyRoFN41x1MDAwM0Y5U8D7SMd7XHUwMDA0YZuXMmtcdTAwMDfhsN+K6NLZmlx1MDAwZqDnXHUwMDA3XHUwMDBmYCmO8LFkflx1MDAwMEX+IT+Cn+/Wf6Z08ZFcdTAwMWNcdTAwMDRYQqhDpKdKO1CKTZWYK+zAzStqqr5LLsMrXGIoz482saBcdTAwMGXnhJpcdTAwMTZYRvGydeZcYqhcdTAwMTVvXHUwMDE5XHUwMDFhcFM0k1xuWPOyiODOXHUwMDFkl17SXHUwMDE12uPSa93YnHSdXHUwMDE54j6q68xcdTAwMThahomHjCZcdTAwMTemVKZcdTAwMDRcdTAwMGWyXHUwMDFh9jI9fFx1MDAwZlx1MDAwZbb22mqwS1eGXHUwMDE3g62jk9bNzs1W0TOsMHI0XHUwMDE2/k+8vSeA5a1cblx1MDAxN81xPVx1MDAxMEVOsI6etKBZYIdcdTAwMDWL7cWsXHUwMDE5oVx1MDAwNZfBQMI8eFx1MDAwME4uyjfu2sXN8cnR4VHz9LDFXHUwMDFhu9X3eVx1MDAwMIBcdTAwMDBcdTAwMDNlLfLyXHUwMDAwTGlkp6RRSFx1MDAwMn2QXHUwMDAyXHUwMDEwUukhZH9cdTAwMTRcdTAwMDWHXHUwMDEwpcapSanGSDGCw0mKhCNcdTAwMDeey9cst+FcdTAwMGWMQpo/ptBzulx1MDAwNrlcdTAwMTJj00FSfqtcdTAwMDPgvfPXJlx1MDAwZtNnLG50u7d/XFx2XHUwMDFlXFyYQCbpb/h0XHUwMDA1n25OXHUwMDEycYZYiErEmYNLkIk/fjHET7fXm3hcdTAwMTDeSOznQ8t7XFyKT6a/NSY/hmkmVGDg5U24789cdTAwMWZ//j+z0fGHIn0= + + + + + RefDoc(business logic)ControllerInitiate PaymentShould have mandate?Initiate ChargeNoYesHas mandate?Initiate Mandated ChargeYesNoInitiate Mandate AcquisitionProcess Mandate Acquisition ResponseProcess Mandated Charge ResponseProcess Charge Responsenew mandategateway flowmandated chargegateway flownew chargegateway flowUser ProceedsUser Proceedse.g. by clicking on a URLor saying "yes" on an IVR callor clicking "continue" in single-page-checkoutAsk User to proceed?Types of mandates e.g.:- Subscription- SEPA Mandate- Preauthorized Charge ("hotel security")- Tokenized Charge ("one click checkout")Wait for user in order todelay eventual timeoutsRender Result{Message, CTGA}on_mandate_<status>on_charge_<status>{Message, Redirect}State: Payment Session Logmay directly continueHook:validate_tx_data \ No newline at end of file From 993ab6d8de79357b1a01642592f313ac3a760249 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 3 May 2024 16:10:06 +0200 Subject: [PATCH 03/31] feat: implement PayZen integration --- .../doctype/payzen_settings/__init__.py | 0 .../payzen_settings/payzen_settings.js | 3 + .../payzen_settings/payzen_settings.json | 151 +++++++ .../payzen_settings/payzen_settings.py | 407 ++++++++++++++++++ .../payzen_settings/test_payzen_settings.py | 24 ++ 5 files changed, 585 insertions(+) create mode 100644 payments/payment_gateways/doctype/payzen_settings/__init__.py create mode 100644 payments/payment_gateways/doctype/payzen_settings/payzen_settings.js create mode 100644 payments/payment_gateways/doctype/payzen_settings/payzen_settings.json create mode 100644 payments/payment_gateways/doctype/payzen_settings/payzen_settings.py create mode 100644 payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py diff --git a/payments/payment_gateways/doctype/payzen_settings/__init__.py b/payments/payment_gateways/doctype/payzen_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.js b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.js new file mode 100644 index 00000000..3b0c5449 --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.js @@ -0,0 +1,3 @@ +frappe.ui.form.on('Payzen Settings', { + +}); diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.json b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.json new file mode 100644 index 00000000..6a474a1f --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.json @@ -0,0 +1,151 @@ +{ + "actions": [], + "autoname": "field:gateway_name", + "creation": "2018-02-05 13:46:12.101852", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "gateway_name", + "brand", + "shop_id", + "challenge_3ds", + "column_break_vtxq", + "use_sandbox", + "static_assets_url", + "api_url", + "section_break_2", + "test_password", + "test_hmac_key", + "test_public_key", + "column_break_xbif", + "production_password", + "production_hmac_key", + "production_public_key" + ], + "fields": [ + { + "fieldname": "gateway_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Payment Gateway Name", + "options": "Company", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "allow_in_quick_entry": 1, + "default": "0", + "fieldname": "use_sandbox", + "fieldtype": "Check", + "label": "Use Sandbox" + }, + { + "bold": 1, + "fieldname": "shop_id", + "fieldtype": "Data", + "label": "Shop ID", + "reqd": 1 + }, + { + "fieldname": "column_break_vtxq", + "fieldtype": "Column Break" + }, + { + "fieldname": "test_password", + "fieldtype": "Password", + "label": "Test Password", + "mandatory_depends_on": "use_sandbox" + }, + { + "fieldname": "column_break_xbif", + "fieldtype": "Column Break" + }, + { + "fieldname": "production_password", + "fieldtype": "Password", + "label": "Production Password", + "mandatory_depends_on": "eval:!doc.use_sandbox" + }, + { + "description": "Used to select api url and static assets url.", + "fieldname": "brand", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Brand", + "options": "Clic&Pay By groupe Cr\u00e9dit du Nord\nCobro Inmediato\nEpayNC\nLyra Collect\nMi Cuenta Web\nPayty\nPayZen India\nPayZen LATAM\nPayZen Brazil\nPayZen Europe\nScellius\nSogecommerce\nSystempay", + "reqd": 1 + }, + { + "fieldname": "static_assets_url", + "fieldtype": "Read Only", + "in_list_view": 1, + "label": "Static Assets URL" + }, + { + "fieldname": "api_url", + "fieldtype": "Read Only", + "in_list_view": 1, + "label": "API URL" + }, + { + "fieldname": "test_public_key", + "fieldtype": "Data", + "label": "Test Public Key", + "mandatory_depends_on": "use_sandbox" + }, + { + "fieldname": "production_public_key", + "fieldtype": "Data", + "label": "Production Public Key", + "mandatory_depends_on": "eval:!doc.use_sandbox" + }, + { + "fieldname": "test_hmac_key", + "fieldtype": "Password", + "label": "Test HMAC Key", + "mandatory_depends_on": "use_sandbox" + }, + { + "fieldname": "production_hmac_key", + "fieldtype": "Password", + "label": "Production HMAC Key", + "mandatory_depends_on": "eval:!doc.use_sandbox" + }, + { + "default": "NO_PREFERENCE", + "documentation_url": "https://docs.lyra.com/en/rest/V4.0/api/playground/Charge/CreatePayment#strongAuthentication", + "fieldname": "challenge_3ds", + "fieldtype": "Select", + "label": "3DS Challenge", + "options": "DISABLED\nCHALLENGE_REQUESTED\nCHALLENGE_MANDATE\nNO_PREFERENCE\nAUTO" + } + ], + "links": [], + "modified": "2024-04-12 16:40:50.615981", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "Payzen Settings", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py new file mode 100644 index 00000000..258baf5e --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py @@ -0,0 +1,407 @@ +# Copyright (c) 2018, Frappe Technologies and contributors +# License: MIT. See LICENSE + +from urllib.parse import urlencode + +import hashlib +import hmac +import json + +import frappe +from frappe import _ +from frappe.integrations.utils import create_request_log, make_post_request +from frappe.utils import call_hook_method, get_url + +from requests.auth import HTTPBasicAuth + +from payments.controllers import PaymentController +from payments.utils import create_payment_gateway +from payments.exceptions import FailedToInitiateFlowError, PayloadIntegrityError + +from payments.types import ( + TxData, + Initiated, + GatewayProcessingResponse, + SessionStates, + FrontendDefaults, +) + +gateway_css = """ +""" + +gateway_js = """ + +""" + +gateway_wrapper = """
+
+ +
+ +
+
+
""" + + +class PayzenSettings(PaymentController): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + api_url: DF.ReadOnly | None + brand: DF.Literal[ + "Clic&Pay By groupe Cr\u00e9dit du Nord", + "Cobro Inmediato", + "EpayNC", + "Lyra Collect", + "Mi Cuenta Web", + "Payty", + "PayZen India", + "PayZen LATAM", + "PayZen Brazil", + "PayZen Europe", + "Scellius", + "Sogecommerce", + "Systempay", + ] + challenge_3ds: DF.Literal[ + "DISABLED", "CHALLENGE_REQUESTED", "CHALLENGE_MANDATE", "NO_PREFERENCE", "AUTO" + ] + gateway_name: DF.Data + production_hmac_key: DF.Password | None + production_password: DF.Password | None + production_public_key: DF.Data | None + shop_id: DF.Data + static_assets_url: DF.ReadOnly | None + test_hmac_key: DF.Password | None + test_password: DF.Password | None + test_public_key: DF.Data | None + use_sandbox: DF.Check + # end: auto-generated types + + supported_currencies = [ + "COP", + ] + flowstates = SessionStates( + success=["Paid"], + pre_authorized=[], + processing=["Running"], + declined=["Unpaid", "Abandoned by User", "Unknown - Not Paid"], + ) + frontend_defaults = FrontendDefaults( + gateway_css=gateway_css, + gateway_js=gateway_js, + gateway_wrapper=gateway_wrapper, + ) + + # source: https://github.com/lyra/flask-embedded-form-examples/blob/master/.env.example + static_urls = { + "Clic&Pay By groupe Crédit du Nord": "https://api-clicandpay.groupecdn.fr/static/", + "Cobro Inmediato": "https://static.cobroinmediato.tech/static/", + "EpayNC": "https://epaync.nc/static/", + "Lyra Collect": "https://api.lyra.com/static/", + "Mi Cuenta Web": "https://static.micuentaweb.pe/static/", + "Payty": "https://static.payty.com/static/", + "PayZen India": "https://secure.payzen.co.in/static/", + "PayZen LATAM": "https://static.payzen.lat/static/", + "PayZen Brazil": "https://api.payzen.com.br/api-payment/", + "PayZen Europe": "https://static.payzen.eu/static/", + "Scellius": "https://api.scelliuspaiement.labanquepostale.fr/static/", + "Sogecommerce": "https://api-sogecommerce.societegenerale.eu/static/", + "Systempay": "https://api.systempay.fr/static/", + } + + # source: https://github.com/lyra/flask-embedded-form-examples/blob/master/.env.example + api_urls = { + "Clic&Pay By groupe Crédit du Nord": "https://api-clicandpay.groupecdn.fr/api-payment/", + "Cobro Inmediato": "https://api.cobroinmediato.tech/api-payment/", + "EpayNC": "https://epaync.nc/api-payment/", + "Lyra Collect": "https://api.lyra.com/api-payment/", + "Mi Cuenta Web": "https://api.micuentaweb.pe/api-payment/", + "Payty": "https://api.payty.com/api-payment/", + "PayZen India": "https://secure.payzen.co.in/api-payment/", + "PayZen LATAM": "https://api.payzen.lat/api-payment/", + "PayZen Brazil": "https://static.payzen.lat/static/", + "PayZen Europe": "https://api.payzen.eu/api-payment/", + "Scellius": "https://api.scelliuspaiement.labanquepostale.fr/api-payment/", + "Sogecommerce": "https://api-sogecommerce.societegenerale.eu/api-payment/", + "Systempay": "https://api.systempay.fr/api-payment/", + } + # Field Helper + + @property + def password(self): + return str.encode( + self.get_password( + fieldname="test_password" if self.use_sandbox else "production_password", + raise_exception=False, + ) + ) + + @property + def hmac_key(self): + return str.encode( + self.get_password( + fieldname="test_hmac_key" if self.use_sandbox else "production_hmac_key", + raise_exception=False, + ) + ) + + @property + def pub_key(self): + return ( + f"{self.shop_id}:{self.test_public_key if self.use_sandbox else self.production_public_key}" + ) + + # Frappe Hooks + + def before_validate(self): + self._set_read_only_fields() + + def validate(self): + self._validate_payzen_credentials() + + def on_update(self): + gateway = "Payzen-" + self.gateway_name + create_payment_gateway(gateway, settings="Payzen Settings", controller=self.gateway_name) + call_hook_method("payment_gateway_enabled", gateway=gateway) + + # Ref Doc Hooks + + def validate_tx_data(self, data): + self._validate_tx_data_amount(data.amount) + self._validate_tx_data_currency(data.currency) + + # Implementations + + def _set_read_only_fields(self): + self.api_url = self.api_urls.get(self.brand) + self.static_assets_url = self.static_urls.get(self.brand) + + def _validate_payzen_credentials(self): + def make_test_request(auth): + return frappe._dict( + make_post_request(url=f"{self.api_url}/V4/Charge/SDKTest", auth=auth, data={"value": "test"}) + ) + + if self.test_password: + try: + password = self.get_password(fieldname="test_password") + result = make_test_request(HTTPBasicAuth(self.shop_id, password)) + if result.status != "SUCCESS" or result.answer.get("value") != "test": + frappe.throw(_("Test credentials seem not valid.")) + except Exception: + frappe.throw(_("Could not validate test credentials.")) + + if self.production_password: + try: + password = self.get_password(fieldname="production_password") + result = make_test_request(HTTPBasicAuth(self.shop_id, password)) + if result.status != "SUCCESS" or result.answer.get("value") != "test": + frappe.throw(_("Production credentials seem not valid.")) + except Exception: + frappe.throw(_("Could not validate production credentials.")) + + def _validate_tx_data_amount(self, amount): + if not amount: + frappe.throw(_("Payment amount cannot be 0")) + + def _validate_tx_data_currency(self, currency): + if currency not in self.supported_currencies: + frappe.throw( + _( + "Please select another payment method. Payzen does not support transactions in currency '{0}'" + ).format(currency) + ) + + # Gateway Lifecyle Hooks + + ## Preflight + + def _patch_tx_data(self, tx_data: TxData) -> TxData: + # payzen requires this to be in the smallest denomination of a currency + # TODO: needs to be modified if other currencies are implemented + tx_data.amount = int(tx_data.amount * 100) # hardcoded: COP factor + return tx_data + + ## Initiation + def _initiate_charge(self) -> Initiated: + tx_data = self.state.tx_data + psl = self.state.psl + btn = frappe.get_cached_doc("Payment Button", psl.button) + + data = { + # payzen receives values in the currency's smallest denomination + "amount": tx_data.amount, + "currency": tx_data.currency, + "orderId": tx_data.reference_docname, + "customer": { + "reference": tx_data.payer_contact.get("full_name"), + }, + "strongAuthentication": self.challenge_3ds, + "contrib": f"ERPNext/{self.name}", + "ipnTargetUrl": get_url( + "./api/method/payments.payment_gateways.doctype.payzen_settings.payzen_settings.notification" + ), + "metadata": { + "psl": psl.name, + "reference_doctype": tx_data.reference_doctype, + "reference_docname": tx_data.reference_docname, + }, + } + if btn.extra_payload: + e = json.loads(btn.extra_payload) + if paymentMethods := e.get("paymentMethods"): + data["paymentMethods"] = paymentMethods + + if email_id := tx_data.payer_contact.get("email_id"): + data["customer"]["email"] = email_id + + res = make_post_request( + url=f"{self.api_url}/V4/Charge/CreatePayment", + auth=HTTPBasicAuth(self.shop_id, self.password), + json=data, + ) + if not res.get("status") == "SUCCESS": + raise FailedToInitiateFlowError( + _("didn't return SUCCESS", context="Payments Gateway Exception"), + data=res, + ) + return Initiated( + correlation_id=res["ticket"], + payload=res["answer"], # we're after its '.formToken' + ) + + ## Response Processing + + def _validate_response(self): + response: GatewayProcessingResponse = self.state.response + type = response.payload.get("type") + if type == "V4/Charge/ProcessPaymentAnswer": + key = self.hmac_key + elif type == "V4/Payment": + key = self.password + else: + raise PayloadIntegrityError() + signature = hmac.new( + key, + response.message, + hashlib.sha256, + ).hexdigest() + if response.hash != signature: + raise PayloadIntegrityError() + + def _process_response_for_charge(self): + psl, tx_data = self.state.psl, self.state.tx_data + response: GatewayProcessingResponse = self.state.response + data = response.payload.get("data") + + orderStatus = data.get("orderStatus") + + if orderStatus == "PAID": + self.flags.status_changed_to = "Paid" + elif orderStatus == "RUNNING": + self.flags.status_changed_to = "Running" + elif orderStatus == "UNPAID": + self.flags.status_changed_to = "Unpaid" + elif orderStatus == "ABANDONED": + self.flags.status_changed_to = "Abandoned by User" + else: + self.flags.status_changed_to = "Unknown - Not Paid" + + def _render_failure_message(self): + psl, tx_data = self.state.psl, self.state.tx_data + response: GatewayProcessingResponse = self.state.response + data = response.payload.get("data") + + txDetails = data["transactions"][0] + errcode = txDetails.get("detailedErrorCode", "NO ERROR CODE") + errdetail = txDetails.get("detailedErrorMessage", "no detail") + return ( + _("Payzen Error Code: {}").format(errcode) + "\n" + _("Error Detail: {}").format(errdetail) + ) + + def _is_server_to_server(self): + response: GatewayProcessingResponse = self.state.response + return "V4/Payment" == response.payload.get("type") + + +@frappe.whitelist(allow_guest=True, methods=["POST"]) +def notification(**kwargs): + kr_hash = kwargs["kr-hash"] + kr_answer = kwargs["kr-answer"] + kr_answer_type = kwargs["kr-answer-type"] + + _kr_hash_key = kwargs["kr-hash-key"] + _kr_hash_algorithm = kwargs["kr-hash-algorithm"] + + if kr_answer_type not in [ + "V4/Payment", # IPN + "V4/Charge/ProcessPaymentAnswer", # Client Flow + ]: # TODO: implemet more + return + if not kr_answer: + return + + data = json.loads(kr_answer) + tx1 = data["transactions"][0] + psl_name = tx1["metadata"]["psl"] + + return PaymentController.process_response( + psl_name=psl_name, + response=GatewayProcessingResponse( + hash=kr_hash, + message=str.encode(kr_answer), + payload={ + "type": kr_answer_type, + "data": data, + }, + ), + ).__dict__ diff --git a/payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py new file mode 100644 index 00000000..4993bf44 --- /dev/null +++ b/payments/payment_gateways/doctype/payzen_settings/test_payzen_settings.py @@ -0,0 +1,24 @@ +import unittest +import frappe + + +class TestPayzenSettings(unittest.TestCase): + pass + + +def create_payzen_settings(payment_gateway_name="Express"): + if frappe.db.exists("Payzen Settings", payment_gateway_name): + return frappe.get_doc("Payzen Settings", payment_gateway_name) + + doc = frappe.get_doc( + doctype="Payzen Settings", + sandbox=1, + payment_gateway_name=payment_gateway_name, + consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", + consumer_secret="VI1oS3oBGPJfh3JyvLHw", + online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd", + till_number="174379", + ) + + doc.insert(ignore_permissions=True) + return doc From 08f59da4247fa786d2a725bd7cc24dee9790e2ae Mon Sep 17 00:00:00 2001 From: David Date: Fri, 3 May 2024 16:47:48 +0200 Subject: [PATCH 04/31] fix: add back compatibility --- payments/utils/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/payments/utils/__init__.py b/payments/utils/__init__.py index 03da563c..aee76796 100644 --- a/payments/utils/__init__.py +++ b/payments/utils/__init__.py @@ -7,3 +7,6 @@ erpnext_app_import_guard, PAYMENT_SESSION_REF_KEY, ) + +# compatibility with older erpnext versions <16 +from payments.utils.utils import get_payment_controller as get_payment_gateway_controller From 0d40a9674996174b66cc4bba7bb5aa096a85581e Mon Sep 17 00:00:00 2001 From: David Date: Sat, 4 May 2024 10:21:06 +0200 Subject: [PATCH 05/31] feat: add app logo to checkout page --- payments/www/pay.html | 10 ++++++++++ payments/www/pay.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/payments/www/pay.html b/payments/www/pay.html index 99ddc8e1..990c9f2f 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -35,12 +35,22 @@

+ {% if logo %} + + {% endif %} {% if render_widget %} +
{{ gateway_wrapper }} +
{% endif %} {% if render_buttons %} diff --git a/payments/www/pay.py b/payments/www/pay.py index ae7894b4..43b99094 100644 --- a/payments/www/pay.py +++ b/payments/www/pay.py @@ -53,6 +53,7 @@ def get_context(context): if not psl.button and psl.status not in ["Paid", "Authorized", "Processing", "Error"]: context.render_widget = False context.render_buttons = True + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] filters = {"enabled": True} # gateway was preselected; e.g. on the backend @@ -80,6 +81,7 @@ def get_context(context): context.render_widget = False context.render_buttons = False context.status = psl.status + context.logo = None match psl.status: case "Paid": context.indicator_color = "green" @@ -95,6 +97,7 @@ def get_context(context): else: context.render_widget = True context.render_buttons = False + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] tx_update = {} # TODO: implement that the user may change some values proceeded: Proceeded = PaymentController.proceed(psl.name, tx_update) From d503255808dc1f745329cd7dd9afcd22f0151c4f Mon Sep 17 00:00:00 2001 From: David Date: Sun, 5 May 2024 22:35:09 +0200 Subject: [PATCH 06/31] feat: add data capture to payment flow --- payments/controllers/payment_controller.py | 26 ++++++++++++- .../payzen_settings/payzen_settings.py | 3 ++ .../doctype/payment_button/payment_button.js | 1 + .../payment_button/payment_button.json | 33 +++++++++++++---- .../doctype/payment_button/payment_button.py | 26 ++++++++++++- .../payment_session_log.json | 19 +++++++++- .../payment_session_log.py | 37 +++++++++++++++++-- payments/types.py | 11 ++++-- payments/www/pay.html | 9 +++++ payments/www/pay.py | 27 +++++++++++--- 10 files changed, 169 insertions(+), 23 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index 9daae9e8..08d9627c 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -146,6 +146,20 @@ def get_payment_url(psl_name: PSLName) -> PaymentUrl | None: } return get_url(f"./pay?{urlencode(params)}") + @staticmethod + def pre_data_capture_hook(psl_name: PSLName) -> dict: + """Call this before presenting the user with a form to capture additional data. + + Implementation is optional, but can be used to acquire any additonal data from the remote + gateway that should be present already during data capture. + """ + + psl: PaymentSessionLog = frappe.get_cached_doc("Payment Session Log", psl_name) + self: "PaymentController" = psl.get_controller() + data = self._pre_data_capture_hook() + psl.update_gateway_specific_state(data, "Data Capture") + return data + @staticmethod def proceed(psl_name: PSLName, updated_tx_data: TxData | None) -> Proceeded: """Call this when the user agreed to proceed with the payment to initiate the capture with @@ -250,6 +264,7 @@ def proceed(psl_name: PSLName, updated_tx_data: TxData | None) -> Proceeded: indicator_color="yellow", ) raise frappe.Redirect + except Exception as e: error = psl.log_error(title="Unknown Initialization Failure") frappe.redirect_to_message( @@ -396,7 +411,7 @@ def _process_response( @staticmethod def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> Processed: - """Call this from the controlling business logic; either backend or frontens. + """Call this from the controlling business logic; either backend or frontend. It will recover the correct controller and dispatch the correct processing based on data that is at this point already stored in the integration log @@ -505,6 +520,15 @@ def _patch_tx_data(self, tx_data: TxData) -> TxData: """ return tx_data + def _pre_data_capture_hook(self) -> dict: + """Optional: Implement additional server side control flow prior to data capture. + For example in order to fetch additional data from the gateway that must be already present + during the data capture. + + This is NOT used in Buttons with the Third Party Widget implementation variant. + """ + return {} + def _should_have_mandate(self) -> bool: """Optional: Define here, if the TxData store in self.state.tx_data should have a mandate. diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py index 258baf5e..1330e8f8 100644 --- a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py @@ -85,6 +85,8 @@ """ +data_capture = "" + class PayzenSettings(PaymentController): # begin: auto-generated types @@ -139,6 +141,7 @@ class PayzenSettings(PaymentController): gateway_css=gateway_css, gateway_js=gateway_js, gateway_wrapper=gateway_wrapper, + data_capture=data_capture, ) # source: https://github.com/lyra/flask-embedded-form-examples/blob/master/.env.example diff --git a/payments/payments/doctype/payment_button/payment_button.js b/payments/payments/doctype/payment_button/payment_button.js index 1f2fd3b1..40b964c0 100644 --- a/payments/payments/doctype/payment_button/payment_button.js +++ b/payments/payments/doctype/payment_button/payment_button.js @@ -10,6 +10,7 @@ frappe.ui.form.on('Payment Button', { frm.set_value('gateway_css', r.message.gateway_css ) frm.set_value('gateway_js', r.message.gateway_js) frm.set_value('gateway_wrapper', r.message.gateway_wrapper) + frm.set_value('data_capture', r.message.data_capture) } }) } diff --git a/payments/payments/doctype/payment_button/payment_button.json b/payments/payments/doctype/payment_button/payment_button.json index 45e62dd2..3a1fe787 100644 --- a/payments/payments/doctype/payment_button/payment_button.json +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -8,6 +8,7 @@ "field_order": [ "gateway_settings", "gateway_controller", + "implementation_variant", "column_break_mjuo", "label", "enabled", @@ -17,6 +18,7 @@ "gateway_css", "gateway_js", "gateway_wrapper", + "data_capture", "extra_payload" ], "fields": [ @@ -35,30 +37,30 @@ "reqd": 1 }, { - "default": "\n", + "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", "fieldname": "gateway_css", "fieldtype": "Code", "ignore_xss_filter": 1, "label": "Gateway CSS", - "mandatory_depends_on": "eval: doc.enabled", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Third Party Widget\"", "options": "HTML" }, { - "default": "\n", + "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", "fieldname": "gateway_js", "fieldtype": "Code", "ignore_xss_filter": 1, "label": "Gateway JS", - "mandatory_depends_on": "eval: doc.enabled", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Third Party Widget\"", "options": "HTML" }, { - "default": "
\n
\n
\n \n
\n
\n
\n \n
\n
\n
\n
", + "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", "fieldname": "gateway_wrapper", "fieldtype": "Code", "ignore_xss_filter": 1, "label": "Gateway Wrapper", - "mandatory_depends_on": "eval: doc.enabled", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Third Party Widget\"", "options": "HTML" }, { @@ -106,11 +108,28 @@ "fieldtype": "Code", "label": "Extra Payload", "options": "JSON" + }, + { + "default": "Third Party Widget", + "fieldname": "implementation_variant", + "fieldtype": "Select", + "label": "Implementation Variant", + "options": "Third Party Widget\nData Capture", + "reqd": 1 + }, + { + "depends_on": "eval: doc.implementation_variant == \"Data Capture\"", + "fieldname": "data_capture", + "fieldtype": "Code", + "ignore_xss_filter": 1, + "label": "Data Capture", + "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Data Capture\"", + "options": "HTML" } ], "image_field": "icon", "links": [], - "modified": "2024-05-04 05:08:57.275983", + "modified": "2024-05-05 11:33:11.418592", "modified_by": "Administrator", "module": "Payments", "name": "Payment Button", diff --git a/payments/payments/doctype/payment_button/payment_button.py b/payments/payments/doctype/payment_button/payment_button.py index e99375b2..5cad2ea1 100644 --- a/payments/payments/doctype/payment_button/payment_button.py +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -5,7 +5,8 @@ import json from frappe import _ from frappe.model.document import Document -from payments.types import RemoteServerInitiationPayload +from payments.types import RemoteServerInitiationPayload, TxData +from payments.payments.doctype.payment_session_log.payment_session_log import PSLState Css = str Js = str @@ -21,6 +22,7 @@ class PaymentButton(Document): if TYPE_CHECKING: from frappe.types import DF + data_capture: DF.Code | None enabled: DF.Check extra_payload: DF.Code | None gateway_controller: DF.DynamicLink @@ -29,6 +31,7 @@ class PaymentButton(Document): gateway_settings: DF.Link gateway_wrapper: DF.Code | None icon: DF.AttachImage | None + implementation_variant: DF.Literal["Third Party Widget", "Data Capture"] label: DF.Data # end: auto-generated types @@ -36,7 +39,7 @@ class PaymentButton(Document): # - imeplement them for your controller # - need to be fully rendered with # --------------------------------------- - def get_assets(self, payload: RemoteServerInitiationPayload) -> (Css, Js, Wrapper): + def get_widget_assets(self, payload: RemoteServerInitiationPayload) -> (Css, Js, Wrapper): """Get the fully rendered frontend assets for this button.""" context = { "doc": frappe.get_cached_doc(self.gateway_settings, self.gateway_controller), @@ -47,6 +50,25 @@ def get_assets(self, payload: RemoteServerInitiationPayload) -> (Css, Js, Wrappe wrapper = frappe.render_template(self.gateway_wrapper, context) return css, js, wrapper + def get_data_capture_assets(self, state: PSLState) -> Wrapper: + """Get the fully rendered data capture form. + + The rendering context is updated with `state`. + """ + context = { + "doc": frappe.get_cached_doc(self.gateway_settings, self.gateway_controller), + "extra": frappe._dict(json.loads(self.extra_payload)), + } + context.update(state) + from pprint import pprint + + pprint(context) + return frappe.render_template(self.data_capture, context) + + @property + def requires_data_catpure(self): + return self.implementation_variant == "Data Capture" + def validate(self): if self.extra_payload: try: diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.json b/payments/payments/doctype/payment_session_log/payment_session_log.json index 3f11ec48..1b7b7e02 100644 --- a/payments/payments/doctype/payment_session_log/payment_session_log.json +++ b/payments/payments/doctype/payment_session_log/payment_session_log.json @@ -11,9 +11,11 @@ "button", "flow_type", "gateway", + "requires_data_capture", "mandate", "correlation_id", "tx_data", + "gateway_specific_state", "initiation_response_payload", "processing_response_payload" ], @@ -97,11 +99,26 @@ "in_standard_filter": 1, "label": "Decline Reason", "read_only": 1 + }, + { + "default": "0", + "fieldname": "requires_data_capture", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Requires Data Capture", + "read_only": 1 + }, + { + "fieldname": "gateway_specific_state", + "fieldtype": "Code", + "label": "Gateway Specific State", + "read_only": 1 } ], "in_create": 1, "links": [], - "modified": "2024-04-15 07:16:05.889449", + "modified": "2024-05-05 12:17:27.224568", "modified_by": "Administrator", "module": "Payments", "name": "Payment Session Log", diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.py b/payments/payments/doctype/payment_session_log/payment_session_log.py index 296d93e1..34d114c3 100644 --- a/payments/payments/doctype/payment_session_log/payment_session_log.py +++ b/payments/payments/doctype/payment_session_log/payment_session_log.py @@ -18,6 +18,20 @@ from payments.controllers import PaymentController from payments.payments.doctype.payment_button.payment_button import PaymentButton +import collections.abc + + +def update(d, u): + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = update(d.get(k, {}), v) + else: + d[k] = v + return d + + +PSLState = dict + class PaymentSessionLog(Document): # begin: auto-generated types @@ -33,19 +47,32 @@ class PaymentSessionLog(Document): decline_reason: DF.Data | None flow_type: DF.Data | None gateway: DF.Data | None + gateway_specific_state: DF.Code | None initiation_response_payload: DF.Code | None mandate: DF.Data | None processing_response_payload: DF.Code | None + requires_data_capture: DF.Check status: DF.Data | None title: DF.Data | None tx_data: DF.Code | None # end: auto-generated types def update_tx_data(self, tx_data: TxData, status: str) -> None: - data = json.loads(self.tx_data) - data.update(tx_data) + d = json.loads(self.tx_data) + update(d, tx_data) + self.db_set( + { + "tx_data": frappe.as_json(d), + "status": status, + }, + commit=True, + ) + + def update_gateway_specific_state(self, data: dict, status: str) -> None: + d = json.loads(self.gateway_specific_state) if self.gateway_specific_state else {} + update(d, data) self.db_set( { - "tx_data": frappe.as_json(data), + "gateway_specific_state": frappe.as_json(d), "status": status, }, commit=True, @@ -73,10 +100,11 @@ def set_processing_payload( commit=True, ) - def load_state(self): + def load_state(self) -> PSLState: return frappe._dict( psl=frappe._dict(self.as_dict()), tx_data=TxData(**json.loads(self.tx_data)), + gateway_data=json.loads(self.gateway_specific_state) if self.gateway_specific_state else {}, ) def get_controller(self) -> "PaymentController": @@ -124,6 +152,7 @@ def select_button(pslName: str = None, buttonName: str = None) -> str: psl.db_set( { "button": buttonName, + "requires_data_capture": btn.requires_data_catpure, "gateway": json.dumps( { "gateway_settings": btn.gateway_settings, diff --git a/payments/types.py b/payments/types.py index 63deba32..44eb8242 100644 --- a/payments/types.py +++ b/payments/types.py @@ -32,18 +32,23 @@ class SessionStates: @dataclass class FrontendDefaults: - """Define gateway frontend defaults for css, js and the wrapper components. + """Define gateway widget frontend defaults for css, js and the wrapper components + or a data capture form. - All three are html snippets and jinja templates rendered against this gateway's + All four are html snippets and jinja templates rendered against this gateway's PaymentController instance and its RemoteServerInitiationPayload. These are loaded into the Payment Button document and give users a starting point - to customize a gateway's payment button(s) + to customize payment button(s). + + If the button implements a third party widget, then gateway_* are rendered into the checkout. + If the button implements first party data capture, then data_capture is rendered. """ gateway_css: str gateway_js: str gateway_wrapper: str + data_capture: str class RemoteServerInitiationPayload(dict): diff --git a/payments/www/pay.html b/payments/www/pay.html index 990c9f2f..25e40cf4 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -43,6 +43,15 @@
alt="{{ _("Brand Logo") }}" > {% endif %} + {% if render_capture %} +
+ {{ data_capture }} +
+ +
+
+ {% endif %} {% if render_widget %}
Date: Sun, 5 May 2024 22:35:42 +0200 Subject: [PATCH 07/31] fix: error reporting --- payments/controllers/payment_controller.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index 08d9627c..bb99d4d2 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -2,6 +2,8 @@ from urllib.parse import urlencode +from requests.exceptions import HTTPError + import frappe from frappe import _ from frappe.model.document import Document @@ -196,6 +198,7 @@ def proceed(psl_name: PSLName, updated_tx_data: TxData | None) -> Proceeded: self.state.mandate: PaymentMandate = self._get_mandate() try: + frappe.flags.integration_request_doc = psl # for linking error logs if self._should_have_mandate() and not self.mandate: self.state.mandate = self._create_mandate() @@ -255,11 +258,26 @@ def proceed(psl_name: PSLName, updated_tx_data: TxData | None) -> Proceeded: payload=initiated.payload, ) + # some gateways don't return HTTP errors ... except FailedToInitiateFlowError as e: psl.set_initiation_payload(e.data, "Error") + error = psl.log_error(title=e.message) + frappe.redirect_to_message( + _("Payment Gateway Error"), + _("Please contact customer care mentioning: {0} and {1}").format(psl, error), + http_status_code=401, + indicator_color="yellow", + ) + raise frappe.Redirect + + # ... yet others do ... + except HTTPError as e: + data = frappe.flags.integration_request.json() + psl.set_initiation_payload(data, "Error") + error = frappe.get_last_doc("Error Log") frappe.redirect_to_message( _("Payment Gateway Error"), - _("Please contact customer care mentioning: {0}").format(psl), + _("Please contact customer care mentioning: {0} and {1}").format(psl, error), http_status_code=401, indicator_color="yellow", ) From 8b560ed3d1a60a252be69ad7bb3e2d17c2382787 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 5 May 2024 22:36:03 +0200 Subject: [PATCH 08/31] fix: polish checkout page --- payments/www/pay.html | 6 +++--- payments/www/pay.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/payments/www/pay.html b/payments/www/pay.html index 25e40cf4..1bd432cc 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -54,8 +54,8 @@
{% endif %} {% if render_widget %}
- {{ gateway_wrapper }} @@ -70,7 +70,7 @@
class='btn btn-primary btn-sm btn-block btn-pay'>{{ primary_button[0] }} {{ primary_button[2] }}
{% for secondary_button in secondary_buttons %}
+ class='btn btn-secondary btn-sm btn-block btn-pay'>{{ secondary_button[0] }} {{ secondary_button[2] }}
{% endfor %} {% endif %} diff --git a/payments/www/pay.js b/payments/www/pay.js index 8fff8b88..61a7c8af 100644 --- a/payments/www/pay.js +++ b/payments/www/pay.js @@ -57,7 +57,7 @@ $(document).on("payload-processed", function (e, r) { $("#message-wrapper").toggle(true) } if (r.message.action) { - const cta = $("#action"); + const cta = $("#action-processed"); cta.html(r.message.action.label) cta.attr("href", r.message.action.href) cta.toggle(true) From 69ecbbbc86871f5dcdbea24eceb898985890ff12 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 6 May 2024 13:10:11 +0200 Subject: [PATCH 09/31] feat: add display of loyalty points and discount --- payments/types.py | 2 ++ payments/www/pay.html | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/payments/types.py b/payments/types.py index 44eb8242..6f1b72f1 100644 --- a/payments/types.py +++ b/payments/types.py @@ -91,6 +91,8 @@ class TxData: reference_docname: str payer_contact: dict # as: contact.as_dict() payer_address: dict # as: address.as_dict() + loyalty_points: list[str, float] | None # for display purpose only + discount_amount: float | None # for display purpose only # TODO: tx data for subscriptions, pre-authorized, require-mandate and other flows diff --git a/payments/www/pay.html b/payments/www/pay.html index 1bd432cc..d62ed811 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -19,12 +19,38 @@
{% block reference_body %} - {% set payer = tx_data.payer_contact %} -

- {{ _("Amount") }}: {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}
- {{ _("Document") }}: {{ tx_data.reference_doctype }}
- {{ _("Customer") }}: {{ payer.get("full_name") }}
-

+ {% set payer = tx_data.payer_contact -%} + {% if tx_data.discount_amount -%} + {% set total = tx_data.amount + tx_data.discount_amount -%} +
+

+
+ {% if tx_data.loyalty_points -%} + {{ _("Used Points") }}:
+ {%- endif %} + {{ _("Discount") }}:
+ {{ _("Amount") }}:
+

+

+ {{ frappe.utils.fmt_money(total, currency=tx_data.currency) }}
+ {% if tx_data.loyalty_points -%} + {{ tx_data.loyalty_points[0] }} {{ tx_data.loyalty_points[1] }}
+ {%- endif %} + - {{ frappe.utils.fmt_money(tx_data.discount_amount, currency=tx_data.currency) }}
+ = {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}
+

+
+

+ {{ _("Document") }}: {{ tx_data.reference_doctype }}
+ {{ _("Customer") }}: {{ payer.get("full_name") }}
+

+ {% else -%} +

+ {{ _("Amount") }}: {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}
+ {{ _("Document") }}: {{ tx_data.reference_doctype }}
+ {{ _("Customer") }}: {{ payer.get("full_name") }}
+

+ {%- endif %} {% endblock %}

From f23a17a40212f55050647d671c029dec20bc3fd9 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 8 May 2024 08:55:11 +0200 Subject: [PATCH 10/31] fix: oversight --- payments/controllers/payment_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index bb99d4d2..231ac5df 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -163,7 +163,7 @@ def pre_data_capture_hook(psl_name: PSLName) -> dict: return data @staticmethod - def proceed(psl_name: PSLName, updated_tx_data: TxData | None) -> Proceeded: + def proceed(psl_name: PSLName, updated_tx_data: TxData = None) -> Proceeded: """Call this when the user agreed to proceed with the payment to initiate the capture with the remote payment gateway. From 0389707dc2ede7e0c27ec284527873bd48666177 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 8 May 2024 10:16:04 +0200 Subject: [PATCH 11/31] fix: payment doc event hooks --- payments/controllers/payment_controller.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index 231ac5df..a4328c9d 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -328,10 +328,14 @@ def __process_response( } try: + ref_doc.flags.payment_session = frappe._dict( + state=self.state, flags=MappingProxyType(self.flags), flowstates=self.flowstates + ) if res := ref_doc.run_method( hookmethod, self.state, MappingProxyType(self.flags), + self.flowstates, ): # type check the result value on user implementations res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ @@ -464,8 +468,8 @@ def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> msg = self._render_failure_message() psl.db_set("failure_reason", msg, commit=True) try: - status = self.flags.status_changed_to - ref_doc.run_method("on_payment_failed", status, msg) + ref_doc.flags.payment_failure_message = msg + ref_doc.run_method("on_payment_failed", msg) except Exception: psl.log_error("Setting failure message on ref doc failed") From 761804f883af74311e307544afb0493ed6111eee Mon Sep 17 00:00:00 2001 From: David Date: Wed, 8 May 2024 12:52:44 +0200 Subject: [PATCH 12/31] fix: run server script after all other hooks --- payments/controllers/payment_controller.py | 39 +++++++++++----------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index a4328c9d..bc84b754 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -17,8 +17,6 @@ from payments.utils import PAYMENT_SESSION_REF_KEY -from types import MappingProxyType - from payments.exceptions import ( FailedToInitiateFlowError, PayloadIntegrityError, @@ -327,23 +325,6 @@ def __process_response( "payload": response.payload, } - try: - ref_doc.flags.payment_session = frappe._dict( - state=self.state, flags=MappingProxyType(self.flags), flowstates=self.flowstates - ) - if res := ref_doc.run_method( - hookmethod, - self.state, - MappingProxyType(self.flags), - self.flowstates, - ): - # type check the result value on user implementations - res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ - _res = _Processed(**res) - processed = Processed(**(ret | _res.__dict__)) - except Exception: - raise RefDocHookProcessingError(f"{hookmethod} failed", psltype) - if self.flags.status_changed_to in self.flowstates.success: psl.set_processing_payload(response, "Paid") ret["indicator_color"] = "green" @@ -391,6 +372,26 @@ def __process_response( **ret, ) + try: + ref_doc.flags.payment_session = frappe._dict( + state=self.state, flags=self.flags, flowstates=self.flowstates + ) # when run as server script: can only set flags + res = ref_doc.run_method( + hookmethod, + self.state, + self.flags, + self.flowstates, + ) + # result from server script run + res = ref_doc.flags.payment_result or res + if res: + # type check the result value on user implementations + res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ + _res = _Processed(**res) + processed = Processed(**(ret | _res.__dict__)) + except Exception: + raise RefDocHookProcessingError(f"{hookmethod} failed", psltype) + return processed def _process_response( From a78444dda084529a7b5368676659545b827e973f Mon Sep 17 00:00:00 2001 From: David Date: Wed, 8 May 2024 16:24:50 +0200 Subject: [PATCH 13/31] fix: ref doc processing --- payments/controllers/payment_controller.py | 15 +++++++++++---- payments/exceptions.py | 3 +-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index bc84b754..16efd4c5 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -325,7 +325,10 @@ def __process_response( "payload": response.payload, } + changed = False + if self.flags.status_changed_to in self.flowstates.success: + changed = "Paid" != psl.status psl.set_processing_payload(response, "Paid") ret["indicator_color"] = "green" processed = processed or Processed( @@ -334,6 +337,7 @@ def __process_response( **ret, ) elif self.flags.status_changed_to in self.flowstates.pre_authorized: + changed = "Authorized" != psl.status psl.set_processing_payload(response, "Authorized") ret["indicator_color"] = "green" processed = processed or Processed( @@ -342,6 +346,7 @@ def __process_response( **ret, ) elif self.flags.status_changed_to in self.flowstates.processing: + changed = "Processing" != psl.status psl.set_processing_payload(response, "Processing") ret["indicator_color"] = "yellow" processed = processed or Processed( @@ -350,6 +355,7 @@ def __process_response( **ret, ) elif self.flags.status_changed_to in self.flowstates.declined: + changed = "Declined" != psl.status psl.db_set("decline_reason", self._render_failure_message()) psl.set_processing_payload(response, "Declined") # commits ret["indicator_color"] = "red" @@ -374,10 +380,11 @@ def __process_response( try: ref_doc.flags.payment_session = frappe._dict( - state=self.state, flags=self.flags, flowstates=self.flowstates + changed=changed, state=self.state, flags=self.flags, flowstates=self.flowstates ) # when run as server script: can only set flags res = ref_doc.run_method( hookmethod, + changed, self.state, self.flags, self.flowstates, @@ -389,8 +396,8 @@ def __process_response( res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ _res = _Processed(**res) processed = Processed(**(ret | _res.__dict__)) - except Exception: - raise RefDocHookProcessingError(f"{hookmethod} failed", psltype) + except Exception as e: + raise RefDocHookProcessingError(psltype) from e return processed @@ -498,7 +505,7 @@ def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> raise frappe.Redirect except RefDocHookProcessingError as e: - error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)") + error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)", e.__cause__) psl.set_processing_payload(response, "Error - RefDoc") if not mute: frappe.redirect_to_message( diff --git a/payments/exceptions.py b/payments/exceptions.py index 51d194f1..a8395aad 100644 --- a/payments/exceptions.py +++ b/payments/exceptions.py @@ -18,6 +18,5 @@ def __init__(self, message, psltype): class RefDocHookProcessingError(Exception): - def __init__(self, message, psltype): - self.message = message + def __init__(self, psltype): self.psltype = psltype From 93c4d9fd097e07cc47ff04f16524544e3384611d Mon Sep 17 00:00:00 2001 From: David Date: Wed, 8 May 2024 16:25:16 +0200 Subject: [PATCH 14/31] fix(payzen): notification processing --- .../doctype/payzen_settings/payzen_settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py index 1330e8f8..724dfcdb 100644 --- a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py @@ -24,6 +24,7 @@ GatewayProcessingResponse, SessionStates, FrontendDefaults, + Processed, ) gateway_css = """ @@ -397,7 +398,7 @@ def notification(**kwargs): tx1 = data["transactions"][0] psl_name = tx1["metadata"]["psl"] - return PaymentController.process_response( + processed: Processed = PaymentController.process_response( psl_name=psl_name, response=GatewayProcessingResponse( hash=kr_hash, @@ -407,4 +408,7 @@ def notification(**kwargs): "data": data, }, ), - ).__dict__ + ) + + if processed: + return processed.__dict__ From 638e16a419fea808b6b71371ad37e878d7792c46 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 19 May 2024 20:22:43 +0200 Subject: [PATCH 15/31] fix: wrong field setter --- payments/controllers/payment_controller.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index 16efd4c5..07e55dd6 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -473,9 +473,8 @@ def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> try: processed = self._process_response(psl, response, ref_doc) if self.flags.status_changed_to in self.flowstates.declined: - msg = self._render_failure_message() - psl.db_set("failure_reason", msg, commit=True) try: + msg = self._render_failure_message() ref_doc.flags.payment_failure_message = msg ref_doc.run_method("on_payment_failed", msg) except Exception: From fb7caea814d2be115f11f3756c84f79204647ca0 Mon Sep 17 00:00:00 2001 From: David Date: Mon, 20 May 2024 10:52:13 +0200 Subject: [PATCH 16/31] fix: declined flow and states; allow to start over --- payments/controllers/payment_controller.py | 16 ++- payments/www/pay.py | 132 ++++++++++----------- 2 files changed, 78 insertions(+), 70 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index 07e55dd6..62221c0f 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -329,7 +329,8 @@ def __process_response( if self.flags.status_changed_to in self.flowstates.success: changed = "Paid" != psl.status - psl.set_processing_payload(response, "Paid") + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, "Paid") # commits ret["indicator_color"] = "green" processed = processed or Processed( message=_("{} succeeded").format(psltype.title()), @@ -338,7 +339,8 @@ def __process_response( ) elif self.flags.status_changed_to in self.flowstates.pre_authorized: changed = "Authorized" != psl.status - psl.set_processing_payload(response, "Authorized") + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, "Authorized") # commits ret["indicator_color"] = "green" processed = processed or Processed( message=_("{} authorized").format(psltype.title()), @@ -347,7 +349,8 @@ def __process_response( ) elif self.flags.status_changed_to in self.flowstates.processing: changed = "Processing" != psl.status - psl.set_processing_payload(response, "Processing") + psl.db_set("decline_reason", None) + psl.set_processing_payload(response, "Processing") # commits ret["indicator_color"] = "yellow" processed = processed or Processed( message=_("{} awaiting further processing by the bank").format(psltype.title()), @@ -356,7 +359,12 @@ def __process_response( ) elif self.flags.status_changed_to in self.flowstates.declined: changed = "Declined" != psl.status - psl.db_set("decline_reason", self._render_failure_message()) + psl.db_set( + { + "decline_reason": self._render_failure_message(), + "button": None, # reset the button for another chance + } + ) psl.set_processing_payload(response, "Declined") # commits ret["indicator_color"] = "red" incoming_email = None diff --git a/payments/www/pay.py b/payments/www/pay.py index 8ccb700d..9d3b88b5 100644 --- a/payments/www/pay.py +++ b/payments/www/pay.py @@ -50,79 +50,79 @@ def get_context(context): state = psl.load_state() context.tx_data: TxData = state.tx_data - # First Pass: chose payment button - if not psl.button and psl.status not in ["Paid", "Authorized", "Processing", "Error"]: - context.render_widget = False - context.render_buttons = True - context.render_capture = False - context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] - filters = {"enabled": True} - - # gateway was preselected; e.g. on the backend - if psl.gateway: - filters.update(json.loads(psl.gateway)) - - buttons = frappe.get_list( - "Payment Button", - fields=["name", "icon", "label"], - filters=filters, - ) - - context.payment_buttons = [ - (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) - for entry in frappe.get_list( + # keep in sync with payment_controller.py + terminal_states = { + "Paid": "green", + "Authorized": "green", + "Processing": "yellow", + "Error": "red", + "Error - RefDoc": "red", + } + + # Not reached a terminal state, yet + # A terminal error state would require operator intervention, first + if psl.status not in terminal_states.keys(): + # First Pass: chose payment button + if not psl.button: + context.render_widget = False + context.render_buttons = True + context.render_capture = False + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + filters = {"enabled": True} + + # gateway was preselected; e.g. on the backend + if psl.gateway: + filters.update(json.loads(psl.gateway)) + + buttons = frappe.get_list( "Payment Button", fields=["name", "icon", "label"], filters=filters, ) - ] - # Thirds Pass: represent status if eligible - # keep in sync with payment_controller.py - elif psl.status in ["Paid", "Authorized", "Processing", "Error", "Error - RefDoc"]: + context.payment_buttons = [ + (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) + for entry in frappe.get_list( + "Payment Button", + fields=["name", "icon", "label"], + filters=filters, + ) + ] + # Second Pass (Data Capture): capture additonal data if the button requires it + elif psl.requires_data_capture: + context.render_widget = False + context.render_buttons = False + context.render_capture = True + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + + proceeded: Proceeded = PaymentController.pre_data_capture_hook(psl.name) + # Display + button: PaymentButton = psl.get_button() + context.data_capture = button.get_data_capture_assets(state) + context.button_name = psl.button + + # Second Pass (Third Party Widget): let the third party widget manage data capture and flow + else: + context.render_widget = True + context.render_buttons = False + context.render_capture = False + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] + + proceeded: Proceeded = PaymentController.proceed(psl.name) + + # Display + payload: RemoteServerInitiationPayload = proceeded.payload + button: PaymentButton = psl.get_button() + css, js, wrapper = button.get_widget_assets(payload) + context.gateway_css = css + context.gateway_js = js + context.gateway_wrapper = wrapper + + # Response processed already: show the result + else: context.render_widget = False context.render_buttons = False context.render_capture = False context.status = psl.status context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] - match psl.status: - case "Paid": - context.indicator_color = "green" - case "Authorized": - context.indicator_color = "green" - case "Processing": - context.indicator_color = "yellow" - case "Error": - context.indicator_color = "red" - case "Error - RefDoc": - context.indicator_color = "red" - - # Second Pass (Data Capture): capture additonal data if the button requires it - elif psl.requires_data_capture: - context.render_widget = False - context.render_buttons = False - context.render_capture = True - context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] - - proceeded: Proceeded = PaymentController.pre_data_capture_hook(psl.name) - # Display - button: PaymentButton = psl.get_button() - context.data_capture = button.get_data_capture_assets(state) - context.button_name = psl.button - - # Second Pass (Third Party Widget): let the third party widget manage data capture and flow - else: - context.render_widget = True - context.render_buttons = False - context.render_capture = False - context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] - - proceeded: Proceeded = PaymentController.proceed(psl.name) - - # Display - payload: RemoteServerInitiationPayload = proceeded.payload - button: PaymentButton = psl.get_button() - css, js, wrapper = button.get_widget_assets(payload) - context.gateway_css = css - context.gateway_js = js - context.gateway_wrapper = wrapper + context.indicator_color = terminal_states.get(psl.status, "gray") From 08fe551214f002a05f8ed41c6f04ecd6a56df0fd Mon Sep 17 00:00:00 2001 From: David Date: Sat, 1 Jun 2024 13:57:39 +0200 Subject: [PATCH 17/31] fix: error cause --- payments/controllers/payment_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index 62221c0f..c1807ae7 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -512,7 +512,7 @@ def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> raise frappe.Redirect except RefDocHookProcessingError as e: - error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)", e.__cause__) + error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)") psl.set_processing_payload(response, "Error - RefDoc") if not mute: frappe.redirect_to_message( From 037e5665f57a64493e59da98fcf3b81b5988eda5 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 2 Jun 2024 11:26:52 +0200 Subject: [PATCH 18/31] feat: separate lp and discount --- payments/types.py | 3 ++- payments/www/pay.html | 17 +++++++++++------ payments/www/pay.py | 4 ++++ 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/payments/types.py b/payments/types.py index 6f1b72f1..b9aa85c8 100644 --- a/payments/types.py +++ b/payments/types.py @@ -91,7 +91,8 @@ class TxData: reference_docname: str payer_contact: dict # as: contact.as_dict() payer_address: dict # as: address.as_dict() - loyalty_points: list[str, float] | None # for display purpose only + # [Program, Points, Amount] + loyalty_points: list[str, float, float] | None # for display purpose only discount_amount: float | None # for display purpose only # TODO: tx data for subscriptions, pre-authorized, require-mandate and other flows diff --git a/payments/www/pay.html b/payments/www/pay.html index d62ed811..51ad7915 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -20,23 +20,28 @@

{% block reference_body %} {% set payer = tx_data.payer_contact -%} - {% if tx_data.discount_amount -%} - {% set total = tx_data.amount + tx_data.discount_amount -%} + {% if has_discount -%}


+ {% if tx_data.discount_amount -%} + {{ _("Discount") }}:
+ {%- endif %} {% if tx_data.loyalty_points -%} {{ _("Used Points") }}:
+ {{ _("Loyalty Benefit") }}:
{%- endif %} - {{ _("Discount") }}:
{{ _("Amount") }}:

- {{ frappe.utils.fmt_money(total, currency=tx_data.currency) }}
+ {{ frappe.utils.fmt_money(grand_total, currency=tx_data.currency) }}
+ {% if tx_data.discount_amount -%} + - {{ frappe.utils.fmt_money(tx_data.discount_amount, currency=tx_data.currency) }}
+ {%- endif %} {% if tx_data.loyalty_points -%} {{ tx_data.loyalty_points[0] }} {{ tx_data.loyalty_points[1] }}
+ - {{ frappe.utils.fmt_money(tx_data.loyalty_points[2], currency=tx_data.currency) }}
{%- endif %} - - {{ frappe.utils.fmt_money(tx_data.discount_amount, currency=tx_data.currency) }}
= {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}

@@ -46,7 +51,7 @@

{% else -%}

- {{ _("Amount") }}: {{ frappe.utils.fmt_money(tx_data.amount, currency=tx_data.currency) }}
+ {{ _("Amount") }}: {{ frappe.utils.fmt_money(grand_total, currency=tx_data.currency) }}
{{ _("Document") }}: {{ tx_data.reference_doctype }}
{{ _("Customer") }}: {{ payer.get("full_name") }}

diff --git a/payments/www/pay.py b/payments/www/pay.py index 9d3b88b5..38531a9a 100644 --- a/payments/www/pay.py +++ b/payments/www/pay.py @@ -49,6 +49,10 @@ def get_context(context): psl: PaymentSessionLog = get_psl() state = psl.load_state() context.tx_data: TxData = state.tx_data + context.grand_total = state.tx_data.amount + (state.tx_data.discount_amount or 0) + if state.tx_data.loyalty_points: + context.grand_total += state.tx_data.loyalty_points[2] + context.has_discount = state.tx_data.discount_amount or state.tx_data.loyalty_points # keep in sync with payment_controller.py terminal_states = { From c232c7ed0f8ae1616203e1b1ee31223716f5f213 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 8 Jun 2024 15:04:45 +0200 Subject: [PATCH 19/31] feat: switch payment method after selecting --- payments/www/pay.html | 4 ++++ payments/www/pay.py | 41 ++++++++++++++++++++--------------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/payments/www/pay.html b/payments/www/pay.html index 51ad7915..efb0172b 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -93,6 +93,10 @@
{% endif %} {% if render_buttons %} + {% if render_widget or render_capture %} +
+
{{ _("or change payment method") }}:
+ {% endif %} {% set primary_button = payment_buttons[0] %} {% set secondary_buttons = payment_buttons[1:] %} diff --git a/payments/www/pay.py b/payments/www/pay.py index 38531a9a..0c345f29 100644 --- a/payments/www/pay.py +++ b/payments/www/pay.py @@ -67,35 +67,35 @@ def get_context(context): # A terminal error state would require operator intervention, first if psl.status not in terminal_states.keys(): # First Pass: chose payment button - if not psl.button: - context.render_widget = False - context.render_buttons = True - context.render_capture = False - context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] - filters = {"enabled": True} - - # gateway was preselected; e.g. on the backend - if psl.gateway: - filters.update(json.loads(psl.gateway)) + # gateway was preselected; e.g. on the backend + filters = {"enabled": True} + if psl.gateway: + filters.update(json.loads(psl.gateway)) + + buttons = frappe.get_list( + "Payment Button", + fields=["name", "icon", "label"], + filters=filters, + ) - buttons = frappe.get_list( + context.payment_buttons = [ + (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) + for entry in frappe.get_list( "Payment Button", fields=["name", "icon", "label"], filters=filters, ) + ] + context.render_buttons = True + + if not psl.button: + context.render_widget = False + context.render_capture = False + context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] - context.payment_buttons = [ - (load_icon(entry.get("icon")), entry.get("name"), entry.get("label")) - for entry in frappe.get_list( - "Payment Button", - fields=["name", "icon", "label"], - filters=filters, - ) - ] # Second Pass (Data Capture): capture additonal data if the button requires it elif psl.requires_data_capture: context.render_widget = False - context.render_buttons = False context.render_capture = True context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] @@ -108,7 +108,6 @@ def get_context(context): # Second Pass (Third Party Widget): let the third party widget manage data capture and flow else: context.render_widget = True - context.render_buttons = False context.render_capture = False context.logo = frappe.get_website_settings("app_logo") or frappe.get_hooks("app_logo_url")[-1] From 4079aef66cfc598176d2dcfad0dce459eccf1e45 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 18 Jun 2024 10:32:14 +0200 Subject: [PATCH 20/31] chore: cleanup & update --- payments/payments/doctype/payment_button/payment_button.json | 4 ++-- payments/payments/doctype/payment_button/payment_button.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/payments/payments/doctype/payment_button/payment_button.json b/payments/payments/doctype/payment_button/payment_button.json index 3a1fe787..00d7f485 100644 --- a/payments/payments/doctype/payment_button/payment_button.json +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -96,7 +96,7 @@ { "collapsible": 1, "collapsible_depends_on": "eval: doc.enabled", - "description": "The code fields of this section are HTML snippets templated via jinja.
\n
{\n  \"doc\":     <Instance of PaymentController>,\n  \"payload\": <Instance of RemoteServerInitiationPayload>,\n}
\nThis jinja context is specific to the gateway.", + "description": "The code fields of this section are HTML snippets templated via jinja.
\n
{\n  \"doc\":     <Instance of PaymentController>,\n  \"extra\":   <Dict of parsed Extra Payload Json>,\n  \"psl\":     <Dict of PSL>,\n  \"tx_data\": <TX Data>,\n  \"gateway_data\": <Dict of Gateway-specific Pre Data Capture State>, \n}
\nThis jinja context is specific to the gateway.", "fieldname": "button_configuration_section", "fieldtype": "Section Break", "label": "Button Configuration" @@ -129,7 +129,7 @@ ], "image_field": "icon", "links": [], - "modified": "2024-05-05 11:33:11.418592", + "modified": "2024-06-17 15:09:04.579657", "modified_by": "Administrator", "module": "Payments", "name": "Payment Button", diff --git a/payments/payments/doctype/payment_button/payment_button.py b/payments/payments/doctype/payment_button/payment_button.py index 5cad2ea1..83930675 100644 --- a/payments/payments/doctype/payment_button/payment_button.py +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -60,9 +60,6 @@ def get_data_capture_assets(self, state: PSLState) -> Wrapper: "extra": frappe._dict(json.loads(self.extra_payload)), } context.update(state) - from pprint import pprint - - pprint(context) return frappe.render_template(self.data_capture, context) @property From 054a9a0549779d4f5475ab15df267820e63a83a4 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 23 Jun 2024 16:44:30 +0200 Subject: [PATCH 21/31] fix: move preflight check to caller responsibility --- payments/controllers/payment_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index c1807ae7..ad6a03c9 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -126,7 +126,7 @@ def initiate( else: self = gateway - self.validate_tx_data(tx_data) # preflight check + # self.validate_tx_data(tx_data) # preflight check psl = create_log( tx_data=tx_data, From 73b311086ac6ed4e526cc1d3ee3ca782d03c9ea8 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 23 Jun 2024 21:24:02 +0200 Subject: [PATCH 22/31] feat: add display reference document --- payments/types.py | 2 ++ payments/www/pay.html | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/payments/types.py b/payments/types.py index b9aa85c8..49425bf0 100644 --- a/payments/types.py +++ b/payments/types.py @@ -94,6 +94,8 @@ class TxData: # [Program, Points, Amount] loyalty_points: list[str, float, float] | None # for display purpose only discount_amount: float | None # for display purpose only + display_reference_doctype: str | None = None + display_reference_docname: str | None = None # TODO: tx data for subscriptions, pre-authorized, require-mandate and other flows diff --git a/payments/www/pay.html b/payments/www/pay.html index efb0172b..59e0ec74 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -15,7 +15,7 @@
- {{ tx_data.reference_docname }} + {{ tx_data.display_reference_docname or tx_data.reference_docname }}
{% block reference_body %} @@ -46,13 +46,13 @@

- {{ _("Document") }}: {{ tx_data.reference_doctype }}
+ {{ _("Document") }}: {{ _(tx_data.display_reference_doctype or tx_data.reference_doctype) }}
{{ _("Customer") }}: {{ payer.get("full_name") }}

{% else -%}

{{ _("Amount") }}: {{ frappe.utils.fmt_money(grand_total, currency=tx_data.currency) }}
- {{ _("Document") }}: {{ tx_data.reference_doctype }}
+ {{ _("Document") }}: {{ _(tx_data.display_reference_doctype or tx_data.reference_doctype) }}
{{ _("Customer") }}: {{ payer.get("full_name") }}

{%- endif %} From 88049c3e62b727b6e903a95c09f5c8c37a47f7ba Mon Sep 17 00:00:00 2001 From: David Date: Sun, 23 Jun 2024 22:16:37 +0200 Subject: [PATCH 23/31] fix: ensure psl is not a hinderance to delete --- payments/hooks.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/payments/hooks.py b/payments/hooks.py index 31705812..7d9b92dc 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -108,6 +108,10 @@ # } # } +ignore_links_on_delete = [ + "Payment Session Log", +] + # Scheduled Tasks # --------------- From 261b6baf9b76bad5f05cc661f7901165b10de849 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 3 Jul 2024 17:48:41 +0200 Subject: [PATCH 24/31] fix: handling of server exceptions --- payments/controllers/payment_controller.py | 107 +++++++++++++++------ 1 file changed, 79 insertions(+), 28 deletions(-) diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index ad6a03c9..dd00227b 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -1,6 +1,6 @@ import json -from urllib.parse import urlencode +from urllib.parse import urlencode, quote from requests.exceptions import HTTPError @@ -9,7 +9,7 @@ from frappe.model.document import Document from frappe.model.base_document import get_controller from frappe.email.doctype.email_account.email_account import EmailAccount -from frappe.desk.form.load import get_automatic_email_link, get_document_email +from frappe.desk.form.load import get_document_email from payments.payments.doctype.payment_session_log.payment_session_log import ( create_log, ) @@ -52,7 +52,7 @@ def _error_value(error, flow): return _( - "Our server had an issue processing your {0}. Please contact customer support mentioning: {1}" + "Our server had a problem processing your {0}. Please contact customer support mentioning: {1}" ).format(flow, error) @@ -367,16 +367,28 @@ def __process_response( ) psl.set_processing_payload(response, "Declined") # commits ret["indicator_color"] = "red" - incoming_email = None - if automatic_linking_email := get_automatic_email_link(): - incoming_email = get_document_email( - self.state.tx_data.reference_doctype, - self.state.tx_data.reference_docname, - ) - if incoming_email := incoming_email or EmailAccount.find_default_incoming(): - subject = _("Payment declined for: {}").format(self.state.tx_data.reference_docname) - body = _("Please help me with ref '{}'").format(psl.name) - href = "mailto:{incoming_email.email_id}?subject={subject}" + + incoming_email = get_document_email( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + if not incoming_email: + email = EmailAccount.find_default_incoming() + incoming_email = email.email_id if email else None + + if incoming_email: + params = { + "subject": _("Help! Payment declined: {}, {}").format( + self.state.tx_data.reference_docname, psl.name + ), + "body": _("Please help me with:\n - PSL: {}\n - RefDoc: {}\n\nThank you!").format( + frappe.utils.get_url_to_form("Payment Session Log", psl.name), + frappe.utils.get_url_to_form( + self.state.tx_data.reference_doctype, self.state.tx_data.reference_docname + ), + ), + } + href = f"mailto:{incoming_email}?{urlencode(params, quote_via=quote)}" action = dict(href=href, label=_("Email Us")) else: action = dict(href=self.get_payment_url(psl.name), label=_("Refresh")) @@ -405,6 +417,15 @@ def __process_response( _res = _Processed(**res) processed = Processed(**(ret | _res.__dict__)) except Exception as e: + # Ensure no details are leaked to the client + frappe.local.message_log = [ + { + "message": _("Server Processing Failure!"), + "subtitle": _("(during RefDoc processing)"), + "body": str(e), + "indicator": "red", + } + ] raise RefDocHookProcessingError(psltype) from e return processed @@ -478,6 +499,34 @@ def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> ) mute = self._is_server_to_server() + + def get_compensatatory_action(error_log): + + incoming_email = get_document_email( + self.state.tx_data.reference_doctype, + self.state.tx_data.reference_docname, + ) + if not incoming_email: + email = EmailAccount.find_default_incoming() + incoming_email = email.email_id if email else None + + if incoming_email: + params = { + "subject": _("Payment Server Error: {}").format(error_log), + "body": _("Reference:\n\n - PSL: {}\n - Error Log: {}\n - RefDoc: {}\n\nThank you!").format( + frappe.utils.get_url_to_form("Payment Session Log", psl.name), + frappe.utils.get_url_to_form("Error Log", error_log.name), + frappe.utils.get_url_to_form( + self.state.tx_data.reference_doctype, self.state.tx_data.reference_docname + ), + ), + } + href = f"mailto:{incoming_email}?{urlencode(params, quote_via=quote)}" + action = dict(href=href, label=_("Email Us")) + else: + action = dict(href="/", label=_("Go to Homepage")) + return action + try: processed = self._process_response(psl, response, ref_doc) if self.flags.status_changed_to in self.flowstates.declined: @@ -486,42 +535,44 @@ def process_response(psl_name: PSLName, response: GatewayProcessingResponse) -> ref_doc.flags.payment_failure_message = msg ref_doc.run_method("on_payment_failed", msg) except Exception: + # Ensure no details are leaked to the client + frappe.local.message_log = [] psl.log_error("Setting failure message on ref doc failed") except PayloadIntegrityError: error = psl.log_error("Response validation failure") if not mute: - frappe.redirect_to_message( - _("Server Error"), - _("There's been an issue with your payment."), - http_status_code=500, + return Processed( + message=_("There's been an issue with your payment."), + action=get_compensatatory_action(error), + status_changed_to=_("Server Error"), indicator_color="red", + payload={}, ) - raise frappe.Redirect except PaymentControllerProcessingError as e: error = psl.log_error(f"Processing error ({e.psltype})") psl.set_processing_payload(response, "Error") if not mute: - frappe.redirect_to_message( - _("Server Error"), - _error_value(error, e.psltype), - http_status_code=500, + return Processed( + message=_error_value(error, e.psltype), + action=get_compensatatory_action(error), + status_changed_to=_("Server Error"), indicator_color="red", + payload={}, ) - raise frappe.Redirect except RefDocHookProcessingError as e: error = psl.log_error(f"Processing failure ({e.psltype} - refdoc hook)") psl.set_processing_payload(response, "Error - RefDoc") if not mute: - frappe.redirect_to_message( - _("Server Error"), - _error_value(error, f"{e.psltype} (via ref doc hook)"), - http_status_code=500, + return Processed( + message=_error_value(error, f"{e.psltype} (via ref doc hook)"), + action=get_compensatatory_action(error), + status_changed_to=_("Server Error"), indicator_color="red", + payload={}, ) - raise frappe.Redirect else: return processed finally: From 19fb58f81d3d3beb3b792e2d4022c9b19716625b Mon Sep 17 00:00:00 2001 From: David Date: Wed, 3 Jul 2024 11:02:19 +0200 Subject: [PATCH 25/31] fix: hide payments buttons after submit --- .../payzen_settings/payzen_settings.py | 34 ++++++++++++++----- payments/www/pay.html | 2 ++ payments/www/pay.js | 6 +++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py index 724dfcdb..3c63b14e 100644 --- a/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py +++ b/payments/payment_gateways/doctype/payzen_settings/payzen_settings.py @@ -54,21 +54,39 @@ }); // KR.openPaymentMethod('CARDS').then().catch() }); - KR.onSubmit(paymentData => { + KR.smartForm.onClick(event => { + $(document).trigger("payment-submitted"); + return true; + }); + KR.onError( function(event) { + $(document).trigger("payment-processed", { + "message": { + "action": {"href": window.location.href, "label": "Retry"}, + "status_changed_to": "Error", + "indicator_color": "red", + "indicator_color": "red", + "message": event.errorMessage, + } + }); + KR.hideForm(event.formId) + return false; + }); + KR.onSubmit(event => { frappe.call({ method:"payments.payment_gateways.doctype.payzen_settings.payzen_settings.notification", freeze: true, headers: {"X-Requested-With": "XMLHttpRequest"}, args: { - "kr-answer": JSON.stringify(paymentData.clientAnswer), - "kr-hash": paymentData.hash, - "kr-hash-algorithm": paymentData.hashAlgorithm, - "kr-hash-key": paymentData.hashKey, - "kr-answer-type": paymentData._type, + "kr-answer": JSON.stringify(event.clientAnswer), + "kr-hash": event.hash, + "kr-hash-algorithm": event.hashAlgorithm, + "kr-hash-key": event.hashKey, + "kr-answer-type": event._type, }, - callback: (r) => $(document).trigger("payload-processed", r), + callback: (r) => $(document).trigger("payment-processed", r), }) - KR.hideForm(paymentData.formId) + KR.hideForm(event.formId) + return false; }); """ diff --git a/payments/www/pay.html b/payments/www/pay.html index 59e0ec74..48dbd95c 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -93,6 +93,7 @@
{% endif %} {% if render_buttons %} +
{% if render_widget or render_capture %}
{{ _("or change payment method") }}:
@@ -108,6 +109,7 @@
class='btn btn-secondary btn-sm btn-block btn-pay'>{{ secondary_button[0] }} {{ secondary_button[2] }}
{% endfor %}
+ {% endif %} diff --git a/payments/www/pay.js b/payments/www/pay.js index 61a7c8af..2128752f 100644 --- a/payments/www/pay.js +++ b/payments/www/pay.js @@ -38,7 +38,11 @@ frappe.ready(function() { }); }); -$(document).on("payload-processed", function (e, r) { +$(document).on("payment-submitted", function (e) { + $("div#button-section").hide() +}) + +$(document).on("payment-processed", function (e, r) { if (r.message.status_changed_to) { const status = r.message.status_changed_to; const color = r.message.indicator_color; From e5a9d14a5ba65de84e97fffa635bd8c3a0999a2c Mon Sep 17 00:00:00 2001 From: David Date: Wed, 3 Jul 2024 11:04:11 +0200 Subject: [PATCH 26/31] feat: add redirect after milliseconds --- payments/types.py | 1 + payments/www/pay.html | 1 + payments/www/pay.js | 15 +++++++++++++++ 3 files changed, 17 insertions(+) diff --git a/payments/types.py b/payments/types.py index 49425bf0..04d3091b 100644 --- a/payments/types.py +++ b/payments/types.py @@ -142,6 +142,7 @@ class Proceeded: class ActionAfterProcessed: href: str label: str + redirect_after_milliseconds: int | None = None @dataclass diff --git a/payments/www/pay.html b/payments/www/pay.html index 48dbd95c..bb4fabe6 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -89,6 +89,7 @@
class='btn btn-primary btn-sm btn-block' style='display: none;' > + {{ gateway_wrapper }} {% endif %} diff --git a/payments/www/pay.js b/payments/www/pay.js index 2128752f..7c914731 100644 --- a/payments/www/pay.js +++ b/payments/www/pay.js @@ -66,5 +66,20 @@ $(document).on("payment-processed", function (e, r) { cta.attr("href", r.message.action.href) cta.toggle(true) cta.focus() + if (r.message.action.redirect_after_milliseconds) { + const message = $("#action-redirect-message"); + const secondsCounter = $("#action-redirect-message-seconds"); + let seconds = Math.floor(r.message.action.redirect_after_milliseconds / 1000); + secondsCounter.html(seconds); + function updateCounter() { + seconds--; + secondsCounter.html(seconds); + } + message.toggle(true) + setInterval(updateCounter, 1000); + setTimeout(function() { + window.location.href = r.message.action.href + }, r.message.action.redirect_after_milliseconds); + } } }) From 1c354217442e9c12ca6874333abab170e2f180e0 Mon Sep 17 00:00:00 2001 From: David Date: Sun, 7 Jul 2024 18:24:27 +0200 Subject: [PATCH 27/31] feat: improve processing hook api --- payments/controllers/payment_controller.py | 35 +++++++++++++------ payments/patches.txt | 4 +++ payments/patches/add_gateway_to_buttons.py | 25 +++++++++++++ .../payment_button/payment_button.json | 22 ++++++++---- .../doctype/payment_button/payment_button.py | 5 +-- .../payment_gateway/payment_gateway.js | 20 ++++++++++- .../payment_gateway/payment_gateway.json | 16 +++++++-- .../payment_gateway/payment_gateway.py | 13 +++++++ .../payment_session_log.py | 34 ++++++++++++++++-- payments/utils/utils.py | 18 ++++++++-- 10 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 payments/patches/add_gateway_to_buttons.py diff --git a/payments/controllers/payment_controller.py b/payments/controllers/payment_controller.py index dd00227b..0a32709d 100644 --- a/payments/controllers/payment_controller.py +++ b/payments/controllers/payment_controller.py @@ -326,8 +326,12 @@ def __process_response( } changed = False + is_success = self.flags.status_changed_to in self.flowstates.success + is_pre_authorized = self.flags.status_changed_to in self.flowstates.pre_authorized + is_processing = self.flags.status_changed_to in self.flowstates.processing + is_declined = self.flags.status_changed_to in self.flowstates.declined - if self.flags.status_changed_to in self.flowstates.success: + if is_success: changed = "Paid" != psl.status psl.db_set("decline_reason", None) psl.set_processing_payload(response, "Paid") # commits @@ -337,7 +341,7 @@ def __process_response( action=dict(href="/", label=_("Go to Homepage")), **ret, ) - elif self.flags.status_changed_to in self.flowstates.pre_authorized: + elif is_pre_authorized: changed = "Authorized" != psl.status psl.db_set("decline_reason", None) psl.set_processing_payload(response, "Authorized") # commits @@ -347,7 +351,7 @@ def __process_response( action=dict(href="/", label=_("Go to Homepage")), **ret, ) - elif self.flags.status_changed_to in self.flowstates.processing: + elif is_processing: changed = "Processing" != psl.status psl.db_set("decline_reason", None) psl.set_processing_payload(response, "Processing") # commits @@ -357,7 +361,7 @@ def __process_response( action=dict(href="/", label=_("Refresh")), **ret, ) - elif self.flags.status_changed_to in self.flowstates.declined: + elif is_declined: changed = "Declined" != psl.status psl.db_set( { @@ -400,17 +404,28 @@ def __process_response( try: ref_doc.flags.payment_session = frappe._dict( - changed=changed, state=self.state, flags=self.flags, flowstates=self.flowstates + changed=changed, + is_success=is_success, + is_pre_authorized=is_pre_authorized, + is_processing=is_processing, + is_declined=is_declined, + state=self.state, + psl=psl, ) # when run as server script: can only set flags res = ref_doc.run_method( hookmethod, - changed, - self.state, - self.flags, - self.flowstates, + status=frappe._dict( + changed=changed, + is_success=is_success, + is_pre_authorized=is_pre_authorized, + is_processing=is_processing, + is_declined=is_declined, + ), + state=self.state, + psl=psl, ) # result from server script run - res = ref_doc.flags.payment_result or res + res = ref_doc.flags.payment_session.result or res if res: # type check the result value on user implementations res["action"] = ActionAfterProcessed(**res.get("action", {})).__dict__ diff --git a/payments/patches.txt b/payments/patches.txt index e69de29b..591f9eb7 100644 --- a/payments/patches.txt +++ b/payments/patches.txt @@ -0,0 +1,4 @@ +[pre_model_sync] +[post_model_sync] +execute:import payments.utils.utils; payments.utils.utils.make_custom_fields() +payments.patches.add_gateway_to_buttons diff --git a/payments/patches/add_gateway_to_buttons.py b/payments/patches/add_gateway_to_buttons.py new file mode 100644 index 00000000..e1963d83 --- /dev/null +++ b/payments/patches/add_gateway_to_buttons.py @@ -0,0 +1,25 @@ +import click +import frappe + + +def execute(): + for button in frappe.get_all( + "Payment Button", fields=["name", "gateway_settings", "gateway_controller"] + ): + gateways = frappe.get_all( + "Payment Gateway", + { + "gateway_settings": button.gateway_settings, + "gateway_controller": button.gateway_controller, + }, + pluck="name", + ) + if len(gateways) > 1: + click.secho( + f"{button} was not migrated: no unabiguous matching gateway found. Set gateway manually", + color="yellow", + ) + continue + button = frappe.get_doc("Payment Button", button.name) + button.payment_gateway = gateways[0] + button.save() diff --git a/payments/payments/doctype/payment_button/payment_button.json b/payments/payments/doctype/payment_button/payment_button.json index 00d7f485..ce64d691 100644 --- a/payments/payments/doctype/payment_button/payment_button.json +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -6,6 +6,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "payment_gateway", "gateway_settings", "gateway_controller", "implementation_variant", @@ -23,18 +24,18 @@ ], "fields": [ { + "fetch_from": "payment_gateway.gateway_settings", "fieldname": "gateway_settings", - "fieldtype": "Link", + "fieldtype": "Data", "label": "Gateway Settings", - "options": "DocType", - "reqd": 1 + "read_only": 1 }, { + "fetch_from": "payment_gateway.gateway_controller", "fieldname": "gateway_controller", - "fieldtype": "Dynamic Link", + "fieldtype": "Data", "label": "Gateway Controller", - "options": "gateway_settings", - "reqd": 1 + "read_only": 1 }, { "depends_on": "eval: doc.implementation_variant == \"Third Party Widget\"", @@ -125,11 +126,18 @@ "label": "Data Capture", "mandatory_depends_on": "eval: doc.enabled && doc.implementation_variant == \"Data Capture\"", "options": "HTML" + }, + { + "fieldname": "payment_gateway", + "fieldtype": "Link", + "label": "Payment Gateway", + "options": "Payment Gateway", + "reqd": 1 } ], "image_field": "icon", "links": [], - "modified": "2024-06-17 15:09:04.579657", + "modified": "2024-07-07 11:59:22.360602", "modified_by": "Administrator", "module": "Payments", "name": "Payment Button", diff --git a/payments/payments/doctype/payment_button/payment_button.py b/payments/payments/doctype/payment_button/payment_button.py index 83930675..f04e1c5b 100644 --- a/payments/payments/doctype/payment_button/payment_button.py +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -25,14 +25,15 @@ class PaymentButton(Document): data_capture: DF.Code | None enabled: DF.Check extra_payload: DF.Code | None - gateway_controller: DF.DynamicLink + gateway_controller: DF.Data | None gateway_css: DF.Code | None gateway_js: DF.Code | None - gateway_settings: DF.Link + gateway_settings: DF.Data | None gateway_wrapper: DF.Code | None icon: DF.AttachImage | None implementation_variant: DF.Literal["Third Party Widget", "Data Capture"] label: DF.Data + payment_gateway: DF.Link # end: auto-generated types # Frontend Assets (widget) diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.js b/payments/payments/doctype/payment_gateway/payment_gateway.js index 0eff5a56..6d40f6c3 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.js +++ b/payments/payments/doctype/payment_gateway/payment_gateway.js @@ -3,6 +3,24 @@ frappe.ui.form.on('Payment Gateway', { refresh: function(frm) { + }, + validate_company: (frm) => { + if (!frm.doc.company) { + frappe.throw({ message: __("Please select a Company first."), title: __("Mandatory") }); + } + }, + setup: function(frm) { + frm.set_query("payment_account", function () { + frm.events.validate_company(frm); - } + var account_types = ["Bank", "Cash"]; + return { + filters: { + account_type: ["in", account_types], + is_group: 0, + company: frm.doc.company, + }, + }; + }); + }, }); diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.json b/payments/payments/doctype/payment_gateway/payment_gateway.json index 2f80da6e..0fd4814e 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.json +++ b/payments/payments/doctype/payment_gateway/payment_gateway.json @@ -8,7 +8,9 @@ "field_order": [ "gateway", "gateway_settings", - "gateway_controller" + "gateway_controller", + "column_break_bkjg", + "company" ], "fields": [ { @@ -30,10 +32,20 @@ "fieldtype": "Dynamic Link", "label": "Gateway Controller", "options": "gateway_settings" + }, + { + "fieldname": "column_break_bkjg", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" } ], "links": [], - "modified": "2022-07-24 21:17:03.864719", + "modified": "2024-07-07 11:40:01.056073", "modified_by": "Administrator", "module": "Payments", "name": "Payment Gateway", diff --git a/payments/payments/doctype/payment_gateway/payment_gateway.py b/payments/payments/doctype/payment_gateway/payment_gateway.py index 74306ae4..bccc5136 100644 --- a/payments/payments/doctype/payment_gateway/payment_gateway.py +++ b/payments/payments/doctype/payment_gateway/payment_gateway.py @@ -5,4 +5,17 @@ class PaymentGateway(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + company: DF.Link | None + gateway: DF.Data + gateway_controller: DF.DynamicLink | None + gateway_settings: DF.Link | None + # end: auto-generated types pass diff --git a/payments/payments/doctype/payment_session_log/payment_session_log.py b/payments/payments/doctype/payment_session_log/payment_session_log.py index 34d114c3..24aedc55 100644 --- a/payments/payments/doctype/payment_session_log/payment_session_log.py +++ b/payments/payments/doctype/payment_session_log/payment_session_log.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from payments.controllers import PaymentController + from payments.payments.doctype.payment_gateway.payment_gateway import PaymentGateway from payments.payments.doctype.payment_button.payment_button import PaymentButton import collections.abc @@ -108,8 +109,17 @@ def load_state(self) -> PSLState: ) def get_controller(self) -> "PaymentController": - """For perfomance reasons, this is not implemented as a dynamic link but a json value - so that it is only fetched when absolutely necessary. + """ + Retrieves the payment controller associated with the current Payment Session Log's gateway. + + This method uses a JSON-encoded gateway value instead of a dynamic link + for performance optimization. The controller is only fetched when necessary. + + Returns: + PaymentController: The cached document of the payment controller. + + Raises: + frappe.ValidationError: If no gateway is selected for this Payment Session Log. """ if not self.gateway: self.log_error("No gateway selected yet") @@ -118,6 +128,25 @@ def get_controller(self) -> "PaymentController": doctype, docname = d["gateway_settings"], d["gateway_controller"] return frappe.get_cached_doc(doctype, docname) + def get_gateway(self) -> "PaymentGateway": + """ + Retrieves the Payment Gateway document associated with the current Payment Session Log. + + The 'gateway' attribute of the Payment Session Log serves a dual purpose, + acting as both a data store and a filter for recovering the Payment Gateway document. + + Returns: + Payment Gateway: The most recent Payment Gateway document matching the stored gateway data. + + Raises: + frappe.ValidationError: If no gateway is selected for this Payment Session Log. + """ + if not self.gateway: + self.log_error("No gateway selected yet") + frappe.throw(_("No gateway selected for this payment session")) + d = json.loads(self.gateway) + return frappe.get_cached_doc("Payment Gateway", d["payment_gateway"]) + def get_button(self) -> "PaymentButton": if not self.button: self.log_error("No button selected yet") @@ -157,6 +186,7 @@ def select_button(pslName: str = None, buttonName: str = None) -> str: { "gateway_settings": btn.gateway_settings, "gateway_controller": btn.gateway_controller, + "payment_gateway": btn.payment_gateway, } ), } diff --git a/payments/utils/utils.py b/payments/utils/utils.py index 2121167e..66bba282 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -175,6 +175,16 @@ def make_custom_fields(): "insert_after": "payment_url", } ], + "Payment Gateway": [ + { + "fieldname": "payment_account", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Payment Account", + "options": "Account", + "insert_after": "company", + } + ], } create_custom_fields(custom_fields) @@ -200,10 +210,14 @@ def delete_custom_fields(): for fieldname in fieldnames: frappe.db.delete("Custom Field", {"name": "Web Form-" + fieldname}) - frappe.db.delete("Custom Field", {"name": "Payment Request-payment_session_log"}) - frappe.clear_cache(doctype="Web Form") + if "erpnext" in frappe.get_installed_apps(): + if frappe.get_meta("Payment Request").has_field("payment_session_log"): + frappe.db.delete("Custom Field", {"name": "Payment Request-payment_session_log"}) + if frappe.get_meta("Payment Gateway").has_field("payment_account"): + frappe.db.delete("Custom Field", {"name": "Payment Gateway-payment_account"}) + def before_install(): # TODO: remove this From d8239fe528269c237dec36e8f7f1aa20a917ff06 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 10 Jul 2024 12:40:35 +0200 Subject: [PATCH 28/31] feat: order buttons by priority --- .../doctype/payment_button/payment_button.json | 13 ++++++++++--- .../doctype/payment_button/payment_button.py | 1 + payments/www/pay.py | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/payments/payments/doctype/payment_button/payment_button.json b/payments/payments/doctype/payment_button/payment_button.json index ce64d691..8ae4b835 100644 --- a/payments/payments/doctype/payment_button/payment_button.json +++ b/payments/payments/doctype/payment_button/payment_button.json @@ -13,6 +13,7 @@ "column_break_mjuo", "label", "enabled", + "priority", "button_configuration_section", "column_break_zwhf", "icon", @@ -133,11 +134,17 @@ "label": "Payment Gateway", "options": "Payment Gateway", "reqd": 1 + }, + { + "fieldname": "priority", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Priority" } ], "image_field": "icon", "links": [], - "modified": "2024-07-07 11:59:22.360602", + "modified": "2024-07-10 05:38:57.786232", "modified_by": "Administrator", "module": "Payments", "name": "Payment Button", @@ -163,7 +170,7 @@ } ], "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", + "sort_field": "priority", + "sort_order": "ASC", "states": [] } \ No newline at end of file diff --git a/payments/payments/doctype/payment_button/payment_button.py b/payments/payments/doctype/payment_button/payment_button.py index f04e1c5b..4b42f84d 100644 --- a/payments/payments/doctype/payment_button/payment_button.py +++ b/payments/payments/doctype/payment_button/payment_button.py @@ -34,6 +34,7 @@ class PaymentButton(Document): implementation_variant: DF.Literal["Third Party Widget", "Data Capture"] label: DF.Data payment_gateway: DF.Link + priority: DF.Int # end: auto-generated types # Frontend Assets (widget) diff --git a/payments/www/pay.py b/payments/www/pay.py index 0c345f29..250e0d4f 100644 --- a/payments/www/pay.py +++ b/payments/www/pay.py @@ -76,6 +76,7 @@ def get_context(context): "Payment Button", fields=["name", "icon", "label"], filters=filters, + order_by="priority", ) context.payment_buttons = [ From e456828636b82e4c86d75a359129e6baedf4d343 Mon Sep 17 00:00:00 2001 From: "David (aider)" Date: Fri, 6 Sep 2024 15:39:15 +0200 Subject: [PATCH 29/31] feat: add debug functionality to payments/www/pay.py and payments/www/pay.html --- payments/www/pay.html | 32 ++++++++++++++++++++++++++++++++ payments/www/pay.py | 2 ++ 2 files changed, 34 insertions(+) diff --git a/payments/www/pay.html b/payments/www/pay.html index bb4fabe6..e9cae249 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -66,6 +66,37 @@

+ {% if debug %} +
+
Debug Information:
+

+ DOM Loaded: Not yet
+ JS Libraries: Checking...
+

+
+ + {% endif %} {% if logo %} frappe.boot = {{boot}} {{ include_script('website-core.bundle.js') }} + {% endblock %} {% set web_include_js=[] %} diff --git a/payments/www/pay.py b/payments/www/pay.py index 250e0d4f..c06d181b 100644 --- a/payments/www/pay.py +++ b/payments/www/pay.py @@ -46,6 +46,8 @@ def get_context(context): # always + context.debug = frappe.form_dict.get('debug') == '1' + psl: PaymentSessionLog = get_psl() state = psl.load_state() context.tx_data: TxData = state.tx_data From dc157d09b0a9c6f5de905a874a8c9c8081345e7f Mon Sep 17 00:00:00 2001 From: "David (aider)" Date: Fri, 6 Sep 2024 16:21:43 +0200 Subject: [PATCH 30/31] feat: Improve library detection using a negative list on window --- payments/www/pay.html | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/payments/www/pay.html b/payments/www/pay.html index e9cae249..2e1ef9c6 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -71,7 +71,7 @@
Debug Information:

DOM Loaded: Not yet
- JS Libraries: Checking...
+ Global Variables: Checking...

{% endif %} From 1802cd7a8ab0d91540030dbf1a416f984588e972 Mon Sep 17 00:00:00 2001 From: "David (aider)" Date: Fri, 6 Sep 2024 16:24:55 +0200 Subject: [PATCH 31/31] feat: Add exclude list for known global variables in pay.html --- payments/www/pay.html | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/payments/www/pay.html b/payments/www/pay.html index 2e1ef9c6..22f94718 100644 --- a/payments/www/pay.html +++ b/payments/www/pay.html @@ -84,8 +84,14 @@
Debug Information:
} }; - // Create a list of default window properties + // Create a list of default window properties and known globals to exclude var defaultProps = Object.getOwnPropertyNames(window); + var excludeList = [ + 'locals', 'NEWLINE', 'TAB', 'UP_ARROW', 'DOWN_ARROW', + 'cur_frm', 'cstr', 'cint', 'toTitle', 'is_null', 'copy_dict', 'validate_email', 'validate_phone', + 'validate_name', 'validate_url', 'nth', 'has_words', 'has_common', 'repl', 'replace_all', 'strip_html', + 'strip', 'lstrip', 'rstrip', '__', 'website', 'valid_email', 'is_html', 'ask_to_login', 'msgprint' + ]; document.addEventListener('DOMContentLoaded', function() { document.getElementById('dom-loaded').textContent = 'Yes'; @@ -96,7 +102,7 @@
Debug Information:
// Find new properties (likely to be libraries or global variables) var newProps = currentProps.filter(function(prop) { - return defaultProps.indexOf(prop) === -1; + return defaultProps.indexOf(prop) === -1 && excludeList.indexOf(prop) === -1; }); var librariesElement = document.getElementById('global-variables');