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": "",
+ "fieldname": "gateway_wrapper",
+ "fieldtype": "Code",
+ "ignore_xss_filter": 1,
+ "label": "Gateway Wrapper",
+ "mandatory_depends_on": "eval: doc.enabled",
+ "options": "HTML"
+ },
+ {
+ "default": "\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 %}
+
+
+
+ {{ _("Status") }}: {{ status or "" }}
+
+ {{ _("Note") }}:
+
+ {% if render_widget %}
+
+ {{ gateway_wrapper }}
+ {% endif %}
+ {% if render_buttons %}
+
+ {% set primary_button = payment_buttons[0] %}
+ {% set secondary_buttons = payment_buttons[1:] %}
+
+
{{ primary_button[0] }} {{ primary_button[2] }}
+ {% for secondary_button in secondary_buttons %}
+
{{ secondary_button[0] }} {{ secondary_button[2] }}
+ {% 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) Controller Initiate Payment Should have mandate? Initiate Charge No Yes Has mandate? Initiate Mandated Charge Yes No Initiate Mandate Acquisition Process Mandate Acquisition Response Process Mandated Charge Response Process Charge Response new mandate gateway flow mandated charge gateway flow new charge gateway flow User Proceeds User Proceeds e.g. by clicking on a URL or saying "yes" on an IVR call or clicking "continue" in single-page-checkout Ask 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 to delay eventual timeouts Render Result {Message, CTGA} on_mandate_<status> on_charge_<status> {Message, Redirect} State: Payment Session Log may directly continue Hook: 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 @@
{{ _("Note") }}:
+ {% if logo %}
+
+ {% endif %}
{% if render_widget %}
+
{% 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": "",
+ "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 %}
+
+ {% 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 %}
{{ secondary_button[0] }} {{ secondary_button[2] }}
+ 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 %}
+
{% 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;'
>
+ {{ _("Redirecting in") }} 10 {{ _("seconds") }}
{{ 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 @@
{{ _("Note") }}:
+ {% 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');